diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 36c02a0f234..250000ef465 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { registerPtyHandlers, buildPtyHostEnv } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' import { selectShellStartupFeatures } from '../shell-startup-features' +import { CODEX_REMOTE_VIEWER_SSH_CONNECTION } from './pty/host-env/codex-terminal-clipboard' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -229,6 +230,40 @@ describe('registerPtyHandlers', () => { } }) + describe('Codex terminal clipboard on headless runtimes (#23530)', () => { + const buildEnv = (launchAgent: 'codex' | 'claude', routeBrowserOpensToClient: boolean) => + buildPtyHostEnv( + 'pty-codex-clipboard', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false, + launchCommand: launchAgent, + launchAgent, + routeBrowserOpensToClient + } + ) + + it.each([ + ['codex', true, CODEX_REMOTE_VIEWER_SSH_CONNECTION], + ['codex', false, undefined], + ['claude', true, undefined] + ] as const)( + 'launching %s with a remote viewer=%s sets SSH_CONNECTION=%s', + (launchAgent, remoteViewer, expected) => { + vi.stubEnv('SSH_CONNECTION', undefined) + vi.stubEnv('SSH_TTY', undefined) + try { + expect(buildEnv(launchAgent, remoteViewer).SSH_CONNECTION).toBe(expected) + } finally { + vi.unstubAllEnvs() + } + } + ) + }) + it('passes the PTY-resolved Codex home to the WSL relay lane', () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 0fe3faf375e..91b1f55b0b0 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -31,6 +31,7 @@ import { import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' +import { applyCodexRemoteViewerClipboardEnv } from './codex-terminal-clipboard' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -292,6 +293,11 @@ export function buildPtyHostEnv( const cliCommand = opts.isWsl ? getWslCliCommandName(opts.isPackaged) : 'orca' baseEnv.BROWSER = `${cliCommand} open-url --url %s` } + // Why the browser-relay flag: both mean a headless runtime whose viewer is another machine. + applyCodexRemoteViewerClipboardEnv(baseEnv, { + launchAgent: opts.launchAgent, + viewerIsRemote: opts.routeBrowserOpensToClient === true + }) // Why: must run after the prepends above — they re-read PATH from the unscrubbed // process.env when baseEnv carries none, which is the daemon path's normal shape. diff --git a/src/main/ipc/pty/host-env/codex-terminal-clipboard.test.ts b/src/main/ipc/pty/host-env/codex-terminal-clipboard.test.ts new file mode 100644 index 00000000000..2ce3103cc83 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-terminal-clipboard.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + applyCodexRemoteViewerClipboardEnv, + CODEX_REMOTE_VIEWER_SSH_CONNECTION +} from './codex-terminal-clipboard' + +describe('applyCodexRemoteViewerClipboardEnv', () => { + it('marks a Codex launch on a host the viewer is not sitting at', () => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: true, + inheritedEnv: {} + }) + expect(env.SSH_CONNECTION).toBe(CODEX_REMOTE_VIEWER_SSH_CONNECTION) + }) + + it('leaves a local Codex launch alone', () => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: false, + inheritedEnv: {} + }) + expect(env.SSH_CONNECTION).toBeUndefined() + }) + + it.each([undefined, 'claude'] as const)('leaves a %s launch alone', (launchAgent) => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { launchAgent, viewerIsRemote: true, inheritedEnv: {} }) + expect(env.SSH_CONNECTION).toBeUndefined() + }) + + it('never overrides an SSH session the launch already carries', () => { + const env: Record = { SSH_CONNECTION: '10.0.0.1 5000 10.0.0.2 22' } + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: true, + inheritedEnv: {} + }) + expect(env.SSH_CONNECTION).toBe('10.0.0.1 5000 10.0.0.2 22') + }) + + it.each([ + ['SSH_CONNECTION', '10.0.0.1 5000 10.0.0.2 22'], + ['SSH_TTY', '/dev/ttys004'] + ])('skips when the host process already inherits %s', (key, value) => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: true, + inheritedEnv: { [key]: value } + }) + expect(env.SSH_CONNECTION).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts b/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts new file mode 100644 index 00000000000..91ecfc523db --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts @@ -0,0 +1,36 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' + +/** + * Codex (rust-v0.162.1 `clipboard_copy.rs`) copies to the clipboard of the machine it + * runs on and sends OSC 52 to the terminal only when SSH_TTY, SSH_CONNECTION or TMUX + * is set. SSH_CONNECTION is the least side-effectful trigger: TMUX makes Codex shell + * out to tmux and wrap OSC 52 in tmux passthrough, and SSH_TTY names a tty device. + * Syntactically valid (client ip/port, server ip/port) for tools that parse it. + */ +export const CODEX_REMOTE_VIEWER_SSH_CONNECTION = '127.0.0.1 0 127.0.0.1 0' + +/** + * Makes an Orca-launched Codex copy through the viewer's terminal (OSC 52) when the + * viewer is on another machine, so selection copy and Ctrl+C reach the user's clipboard. + * Scoped to Codex launches: bare shells keep their real SSH posture. (#23530) + */ +export function applyCodexRemoteViewerClipboardEnv( + env: Record, + options: { + launchAgent: TuiAgent | undefined + viewerIsRemote: boolean + inheritedEnv?: NodeJS.ProcessEnv + } +): void { + if (options.launchAgent !== 'codex' || !options.viewerIsRemote) { + return + } + const inherited = options.inheritedEnv ?? process.env + // Why process.env too: daemon spawns merge the host's inherited env after this build. + const alreadySsh = ['SSH_CONNECTION', 'SSH_TTY'].some( + (key) => env[key] !== undefined || inherited[key] !== undefined + ) + if (!alreadySsh) { + env.SSH_CONNECTION = CODEX_REMOTE_VIEWER_SSH_CONNECTION + } +}