diff --git a/src/cli/handlers/orchestration/terminal-identity-codex-filtered.test.ts b/src/cli/handlers/orchestration/terminal-identity-codex-filtered.test.ts new file mode 100644 index 00000000000..558ab0c8123 --- /dev/null +++ b/src/cli/handlers/orchestration/terminal-identity-codex-filtered.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RuntimeClientError } from '../../runtime-client' +import { throwNoActiveSenderTerminal } from './terminal-identity' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +function refusal(): RuntimeClientError { + try { + throwNoActiveSenderTerminal() + } catch (err) { + if (err instanceof RuntimeClientError) { + return err + } + throw err + } +} + +describe('no_active_sender_terminal inside a Codex command', () => { + it("names Codex's shell_environment_policy when Codex dropped the terminal identity", () => { + vi.stubEnv('CODEX_THREAD_ID', 'thread-1') + vi.stubEnv('ORCA_TERMINAL_HANDLE', '') + vi.stubEnv('ORCA_STRUCTURED_SESSION', '') + + const err = refusal() + + expect(err.code).toBe('no_active_sender_terminal') + expect(err.message).toContain('shell_environment_policy') + expect(err.message).toContain('inherit = "all" with include_only') + }) + + it('keeps the generic advice outside Codex', () => { + vi.stubEnv('CODEX_THREAD_ID', '') + vi.stubEnv('ORCA_TERMINAL_HANDLE', '') + vi.stubEnv('ORCA_STRUCTURED_SESSION', '') + + const err = refusal() + + expect(err.code).toBe('no_active_sender_terminal') + expect(err.message).not.toContain('shell_environment_policy') + }) +}) diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 8cc4f3357e0..c3fefe71027 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -237,6 +237,18 @@ export function throwNoActiveSenderTerminal(): never { if (hasStructuredSessionMarker() && !process.env.ORCA_TERMINAL_HANDLE) { throw structuredSessionRefusal('from') } + // Why: Codex sets CODEX_THREAD_ID after its env filter, which is what drops Orca's (#25775). + if (process.env.CODEX_THREAD_ID && !process.env.ORCA_TERMINAL_HANDLE) { + throw new RuntimeClientError( + 'no_active_sender_terminal', + 'Codex ran this orchestration command without ORCA_TERMINAL_HANDLE. If Codex was started in ' + + 'an Orca terminal, its shell_environment_policy removed it, and relaunching the same way ' + + 'will not restore it. Start Codex by typing `codex` in an Orca terminal, or set ' + + 'inherit = "all" with include_only listing the variables to keep plus "ORCA_*". ' + + "Pass --from only with your own terminal's handle — another pane's handle would act on " + + 'its mailbox.' + ) + } throw new RuntimeClientError( 'no_active_sender_terminal', 'Could not determine the sender terminal for this orchestration command. ' + diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index a3043879132..6b7a63ba196 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -123,13 +123,23 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # which otherwise rewrites this header at parse time and aborts the whole file. function codex { # Why local: zsh's warn_create_global warns for each global a function creates. - local __orca_codex_arg __orca_codex_isolate="${ORCA_CODEX_ISOLATE:-1}" + local __orca_codex_arg __orca_codex_isolate="${ORCA_CODEX_ISOLATE:-1}" __orca_codex_identity="${ORCA_CODEX_ISOLATE:-1}" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : fi for __orca_codex_arg in "$@"; do - case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + case "$__orca_codex_arg" in + --no-daemon) __orca_codex_isolate=0 ;; + agents|queue|--remote|--remote=*) __orca_codex_isolate=0 __orca_codex_identity=0 ;; + esac done + if [[ "$__orca_codex_identity" != 0 ]]; then + [[ -z "${ORCA_TERMINAL_HANDLE:-}" || "${ORCA_TERMINAL_HANDLE}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_TERMINAL_HANDLE='${ORCA_TERMINAL_HANDLE}'" "$@" + [[ -z "${ORCA_PANE_KEY:-}" || "${ORCA_PANE_KEY}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_PANE_KEY='${ORCA_PANE_KEY}'" "$@" + [[ -z "${ORCA_WORKTREE_ID:-}" || "${ORCA_WORKTREE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKTREE_ID='${ORCA_WORKTREE_ID}'" "$@" + [[ -z "${ORCA_WORKSPACE_ID:-}" || "${ORCA_WORKSPACE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKSPACE_ID='${ORCA_WORKSPACE_ID}'" "$@" + [[ -z "${ORCA_USER_DATA_PATH:-}" || "${ORCA_USER_DATA_PATH}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_USER_DATA_PATH='${ORCA_USER_DATA_PATH}'" "$@" + fi # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. if [[ "$__orca_codex_isolate" != 0 ]]; then case "$(command codex --help 2>/dev/null /dev/null 2>&1; or true end - if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '^(agents|queue|--no-daemon|--remote|--remote=.*)$' $argv; and command codex --help 2>/dev/null /dev/null /dev/null 2>&1 || : fi for __orca_codex_arg in "$@"; do - case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + case "$__orca_codex_arg" in + --no-daemon) __orca_codex_isolate=0 ;; + agents|queue|--remote|--remote=*) __orca_codex_isolate=0 __orca_codex_identity=0 ;; + esac done + if [[ "$__orca_codex_identity" != 0 ]]; then + [[ -z "${ORCA_TERMINAL_HANDLE:-}" || "${ORCA_TERMINAL_HANDLE}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_TERMINAL_HANDLE='${ORCA_TERMINAL_HANDLE}'" "$@" + [[ -z "${ORCA_PANE_KEY:-}" || "${ORCA_PANE_KEY}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_PANE_KEY='${ORCA_PANE_KEY}'" "$@" + [[ -z "${ORCA_WORKTREE_ID:-}" || "${ORCA_WORKTREE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKTREE_ID='${ORCA_WORKTREE_ID}'" "$@" + [[ -z "${ORCA_WORKSPACE_ID:-}" || "${ORCA_WORKSPACE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKSPACE_ID='${ORCA_WORKSPACE_ID}'" "$@" + [[ -z "${ORCA_USER_DATA_PATH:-}" || "${ORCA_USER_DATA_PATH}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_USER_DATA_PATH='${ORCA_USER_DATA_PATH}'" "$@" + fi # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. if [[ "$__orca_codex_isolate" != 0 ]]; then case "$(command codex --help 2>/dev/null /dev/null 2>&1 || : fi for __orca_codex_arg in "$@"; do - case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + case "$__orca_codex_arg" in + --no-daemon) __orca_codex_isolate=0 ;; + agents|queue|--remote|--remote=*) __orca_codex_isolate=0 __orca_codex_identity=0 ;; + esac done + if [[ "$__orca_codex_identity" != 0 ]]; then + [[ -z "${ORCA_TERMINAL_HANDLE:-}" || "${ORCA_TERMINAL_HANDLE}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_TERMINAL_HANDLE='${ORCA_TERMINAL_HANDLE}'" "$@" + [[ -z "${ORCA_PANE_KEY:-}" || "${ORCA_PANE_KEY}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_PANE_KEY='${ORCA_PANE_KEY}'" "$@" + [[ -z "${ORCA_WORKTREE_ID:-}" || "${ORCA_WORKTREE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKTREE_ID='${ORCA_WORKTREE_ID}'" "$@" + [[ -z "${ORCA_WORKSPACE_ID:-}" || "${ORCA_WORKSPACE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKSPACE_ID='${ORCA_WORKSPACE_ID}'" "$@" + [[ -z "${ORCA_USER_DATA_PATH:-}" || "${ORCA_USER_DATA_PATH}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_USER_DATA_PATH='${ORCA_USER_DATA_PATH}'" "$@" + fi # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. if [[ "$__orca_codex_isolate" != 0 ]]; then case "$(command codex --help 2>/dev/null /dev/null 2>&1; or true end - if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '^(agents|queue|--no-daemon|--remote|--remote=.*)$' $argv; and command codex --help 2>/dev/null /dev/null /dev/null 2>&1 || : fi for __orca_codex_arg in "$@"; do - case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + case "$__orca_codex_arg" in + --no-daemon) __orca_codex_isolate=0 ;; + agents|queue|--remote|--remote=*) __orca_codex_isolate=0 __orca_codex_identity=0 ;; + esac done + if [[ "$__orca_codex_identity" != 0 ]]; then + [[ -z "${ORCA_TERMINAL_HANDLE:-}" || "${ORCA_TERMINAL_HANDLE}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_TERMINAL_HANDLE='${ORCA_TERMINAL_HANDLE}'" "$@" + [[ -z "${ORCA_PANE_KEY:-}" || "${ORCA_PANE_KEY}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_PANE_KEY='${ORCA_PANE_KEY}'" "$@" + [[ -z "${ORCA_WORKTREE_ID:-}" || "${ORCA_WORKTREE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKTREE_ID='${ORCA_WORKTREE_ID}'" "$@" + [[ -z "${ORCA_WORKSPACE_ID:-}" || "${ORCA_WORKSPACE_ID}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_WORKSPACE_ID='${ORCA_WORKSPACE_ID}'" "$@" + [[ -z "${ORCA_USER_DATA_PATH:-}" || "${ORCA_USER_DATA_PATH}" == *"'"* ]] || set -- -c "shell_environment_policy.set.ORCA_USER_DATA_PATH='${ORCA_USER_DATA_PATH}'" "$@" + fi # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. if [[ "$__orca_codex_isolate" != 0 ]]; then case "$(command codex --help 2>/dev/null { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(49) + expect(PROTOCOL_VERSION).toBe(50) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index c870ace9fa2..2c384758c76 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,4 +1,5 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. +// v50 makes the `codex` function keep Orca's terminal identity under a filtering shell_environment_policy. // v49 makes the `claude` function follow the selection over the user's own CLAUDE_CONFIG_DIR. // v48 skips v47, which the 1.4.227 release (1.4.224 plus picks) took; its daemon is adopted as previous. // v46 starts each new Linux terminal in its own systemd scope beside the daemon's. @@ -7,7 +8,7 @@ // v43 dropped it with the revert); v41 stages long startup commands as sourced scripts; v40 rolled // the #25130/#24636 shell-wrapper changes and the wider agent list into a fresh daemon; older owners // stay attachable. -export const PROTOCOL_VERSION = 49 +export const PROTOCOL_VERSION = 50 export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 export const CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION = 43 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. @@ -41,7 +42,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/macos-daemon-launchd.test.ts b/src/main/daemon/macos-daemon-launchd.test.ts index 3be3b071fcf..80dc1cc9076 100644 --- a/src/main/daemon/macos-daemon-launchd.test.ts +++ b/src/main/daemon/macos-daemon-launchd.test.ts @@ -253,7 +253,7 @@ it('uses one launchd label per profile and protocol', () => { expect(macDaemonJobLabel('/Users/a/Library/Application Support/orca')).not.toBe( macDaemonJobLabel('/Users/a/Library/Application Support/orca-dev') ) - expect(macDaemonJobLabel('/p')).toMatch(/^com\.stablyai\.orca\.terminal\.[0-9a-f]{16}\.v49$/) + expect(macDaemonJobLabel('/p')).toMatch(/^com\.stablyai\.orca\.terminal\.[0-9a-f]{16}\.v50$/) }) it.each(['13.4.1', '12.7.6'])( diff --git a/src/main/providers/windows-shell-preflight-runtime.windows.test.ts b/src/main/providers/windows-shell-preflight-runtime.windows.test.ts index 91f228b6e85..a350de4c0b1 100644 --- a/src/main/providers/windows-shell-preflight-runtime.windows.test.ts +++ b/src/main/providers/windows-shell-preflight-runtime.windows.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import * as pty from 'node-pty' import { afterEach, describe, expect, it } from 'vitest' +import { CODEX_COMMAND_IDENTITY_ENV_KEYS } from '../../shared/codex-shell-function' import { resolveGitBashPath } from '../git-bash' import { ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, @@ -188,6 +189,8 @@ describeWindows('Windows Codex shell preflight runtime', () => { cwd: root, env: { ...withPathEntry(process.env, join(root, 'bin')), + // Why: the fixture codex is node, which reads the forwarded identity's `-c` as --check. + ...Object.fromEntries(CODEX_COMMAND_IDENTITY_ENV_KEYS.map((key) => [key, ''])), CHERE_INVOKING: '1', HOME: root, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight, diff --git a/src/main/pty/codex-no-daemon-binary-contract.test.ts b/src/main/pty/codex-no-daemon-binary-contract.test.ts index 1e0116f2688..535289dc036 100644 --- a/src/main/pty/codex-no-daemon-binary-contract.test.ts +++ b/src/main/pty/codex-no-daemon-binary-contract.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { CODEX_SHARED_SERVER_ARGS } from '../../shared/codex-shell-function' +import { CODEX_NO_DAEMON_SKIP_ARGS } from '../../shared/codex-shell-function' // Why: Orca's codex shell wrapper puts --no-daemon first for every subcommand but // agents/queue (src/shared/codex-shell-function.ts). Its tests use a fake codex, so @@ -13,7 +13,7 @@ import { CODEX_SHARED_SERVER_ARGS } from '../../shared/codex-shell-function' const execFileAsync = promisify(execFile) const binary = process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY const expectedVersion = process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION -const WRAPPER_SKIPPED_SUBCOMMANDS: ReadonlySet = new Set(CODEX_SHARED_SERVER_ARGS) +const WRAPPER_SKIPPED_SUBCOMMANDS: ReadonlySet = new Set(CODEX_NO_DAEMON_SKIP_ARGS) const TIMEOUT_MS = 30_000 describe.runIf(process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED === '1' && !binary)( diff --git a/src/main/pty/codex-no-daemon-launch-command.ts b/src/main/pty/codex-no-daemon-launch-command.ts index 55e1c162f8f..e3261af54c3 100644 --- a/src/main/pty/codex-no-daemon-launch-command.ts +++ b/src/main/pty/codex-no-daemon-launch-command.ts @@ -3,10 +3,10 @@ import { runProcess } from '@orca/process-host' import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' -import { CODEX_SHARED_SERVER_ARGS } from '../../shared/codex-shell-function' +import { CODEX_NO_DAEMON_SKIP_ARGS } from '../../shared/codex-shell-function' const CODEX_EXECUTABLE = /^codex(\.(exe|cmd|bat|ps1))?$/i -const SHARED_SERVER_ARGS: ReadonlySet = new Set(CODEX_SHARED_SERVER_ARGS) +const NO_DAEMON_SKIP_ARGS: ReadonlySet = new Set(CODEX_NO_DAEMON_SKIP_ARGS) // Why bounded: a hung binary must not hold the pane; a slow probe only costs the flag. const HELP_PROBE_TIMEOUT_MS = 5_000 @@ -61,7 +61,7 @@ export function planCodexNoDaemonLaunch(launch: LocalCodexLaunch): Promise SHARED_SERVER_ARGS.has(arg) || arg.startsWith('--remote=')) + args.some((arg) => NO_DAEMON_SKIP_ARGS.has(arg) || arg.startsWith('--remote=')) ) { return null } diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 77ba5b4fa73..d1bba39c672 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -15,6 +15,7 @@ import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight' import { + CODEX_COMMAND_IDENTITY_ENV_KEYS, getFishCodexShellLaunchPreflight, getPosixCodexShellLaunchPreflight, getPowerShellCodexShellLaunchPreflight @@ -178,7 +179,9 @@ function expectNamedAliasSurvives(shell: string, enableAliases: string): void { env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, - ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, 'orca-test') + ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, 'orca-test'), + // Why: a run inside an Orca terminal inherits its identity, which the wrapper forwards. + ...Object.fromEntries(CODEX_COMMAND_IDENTITY_ENV_KEYS.map((key) => [key, ''])) } } ) diff --git a/src/main/pty/codex-shell-no-daemon.test.ts b/src/main/pty/codex-shell-no-daemon.test.ts index 281a51d09bc..30cfa88fbaf 100644 --- a/src/main/pty/codex-shell-no-daemon.test.ts +++ b/src/main/pty/codex-shell-no-daemon.test.ts @@ -12,6 +12,7 @@ import { delimiter, join } from 'node:path' import { spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { + CODEX_COMMAND_IDENTITY_ENV_KEYS, getFishCodexShellLaunchPreflight, getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' @@ -188,6 +189,8 @@ function run( ...process.env, PATH: `${sandbox.bin}${delimiter}${process.env.PATH ?? ''}`, CODEX_HOME: join(sandbox.bin, '..', 'codex-home'), + // Why: a run inside an Orca terminal inherits its identity, which the wrapper forwards. + ...Object.fromEntries(CODEX_COMMAND_IDENTITY_ENV_KEYS.map((key) => [key, ''])), ...env } }) @@ -290,6 +293,90 @@ describe('codex wrapper --no-daemon rule', () => { }) } + describe('caller identity for commands Codex runs', () => { + const identity = { + ORCA_TERMINAL_HANDLE: 'term_abc', + ORCA_USER_DATA_PATH: '/Users/a b/Library/Application Support/orca $HOME \\x "q"' + } + const identityOverrides = [ + "shell_environment_policy.set.ORCA_TERMINAL_HANDLE='term_abc'", + `shell_environment_policy.set.ORCA_USER_DATA_PATH='${identity.ORCA_USER_DATA_PATH}'` + ] + // Why unordered: the overrides set distinct keys, so only which ones arrive matters. + const splitOverrides = (line: string): { overrides: string[]; rest: string[] } => { + const [, ...words] = line.split('|') + const overrides: string[] = [] + const rest: string[] = [] + for (let i = 0; i < words.length; i++) { + if (words[i] === '-c') { + overrides.push(words[++i]) + } else { + rest.push(words[i]) + } + } + return { overrides: overrides.toSorted(), rest } + } + + for (const [shell, available] of shells) { + it.skipIf(!available)( + `${shell} forwards set identity keys, except where the shared server is chosen`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run( + shell, + [ + codexCall(shell, ['x']), + codexCall(shell, ['--no-daemon', 'x']), + codexCall(shell, ['agents']), + codexCall(shell, ['--remote', 'ws://h']) + ].join('\n'), + sandbox, + identity + ) + + expect(result.stderr).toBe('') + expect(lines(result.stdout).map(splitOverrides)).toEqual([ + { overrides: identityOverrides, rest: ['--no-daemon', 'x'] }, + { overrides: identityOverrides, rest: ['--no-daemon', 'x'] }, + { overrides: [], rest: ['agents'] }, + { overrides: [], rest: ['--remote', 'ws://h'] } + ]) + } + ) + + it.skipIf(!available)(`${shell} forwards identity to Codex 0.155 and older too`, () => { + const sandbox = makeSandbox(HELP_WITHOUT_FLAG) + const result = run(shell, codexCall(shell, ['x']), sandbox, identity) + + expect(lines(result.stdout).map(splitOverrides)).toEqual([ + { overrides: identityOverrides, rest: ['x'] } + ]) + }) + + it.skipIf(!available)(`${shell} skips a value holding an apostrophe`, () => { + const sandbox = makeSandbox(HELP_WITHOUT_FLAG) + const result = run(shell, codexCall(shell, ['x']), sandbox, { + ORCA_TERMINAL_HANDLE: 'term_abc', + ORCA_USER_DATA_PATH: "/Users/o'brien/orca'" + }) + + expect(lines(result.stdout).map(splitOverrides)).toEqual([ + { overrides: [identityOverrides[0]], rest: ['x'] } + ]) + }) + + it.skipIf(!available)(`${shell} forwards nothing with ORCA_CODEX_ISOLATE=0`, () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run(shell, codexCall(shell, ['x']), sandbox, { + ...identity, + ORCA_CODEX_ISOLATE: '0' + }) + + expect(lines(result.stdout)).toEqual(['ARGV|x']) + }) + } + }) + for (const [shell, enableAliases] of [ ['bash', 'shopt -s expand_aliases'], ['zsh', 'setopt aliases'] diff --git a/src/shared/codex-shell-function.ts b/src/shared/codex-shell-function.ts index 1e56da76d4f..e35cbe79dc1 100644 --- a/src/shared/codex-shell-function.ts +++ b/src/shared/codex-shell-function.ts @@ -1,9 +1,33 @@ // Orca's `codex` shell function for every shell family: runs launch prep and adds // --no-daemon. Pure text, so any host that writes a shell script can embed it. // Why --no-daemon: Codex 0.156+ otherwise shares one server per CODEX_HOME that runs every tab's -// hooks with the first tab's Orca env and dies with it (#22873). These args need that server or exit 2. -export const CODEX_SHARED_SERVER_ARGS = ['agents', 'queue', '--no-daemon', '--remote'] as const -const CODEX_SHARED_SERVER_ARG_PATTERN = `^(${CODEX_SHARED_SERVER_ARGS.join('|')}|--remote=.*)$` +// hooks with the first tab's Orca env and dies with it (#22873). +// These args need that server (or exit 2 beside the flag), and Codex counts any `-c` as leaving it. +const CODEX_SHARED_SERVER_ARGS = ['agents', 'queue', '--remote'] as const +// An explicit --no-daemon needs no second one. +export const CODEX_NO_DAEMON_SKIP_ARGS = [...CODEX_SHARED_SERVER_ARGS, '--no-daemon'] as const +const argPattern = (args: readonly string[]): string => `^(${args.join('|')}|--remote=.*)$` +const CODEX_SHARED_SERVER_ARG_PATTERN = argPattern(CODEX_SHARED_SERVER_ARGS) +const CODEX_NO_DAEMON_SKIP_ARG_PATTERN = argPattern(CODEX_NO_DAEMON_SKIP_ARGS) + +/** + * The caller identity `orca` reads, which a user's `shell_environment_policy` (`inherit = "core"`) + * strips from every command Codex runs (#25775). Codex applies `set` after `inherit` and `exclude` + * (so an `exclude = ["ORCA_*"]` is overridden) but before `include_only`, which still wins. Never a + * token: argv is readable by every local user. + */ +export const CODEX_COMMAND_IDENTITY_ENV_KEYS = [ + 'ORCA_TERMINAL_HANDLE', + 'ORCA_PANE_KEY', + 'ORCA_WORKTREE_ID', + 'ORCA_WORKSPACE_ID', + 'ORCA_USER_DATA_PATH' +] as const + +// Why values holding `'` are skipped: a TOML literal can't carry one, and Codex's fallback for the +// failed parse truncates some (`/a'`, `/a' #b`) — a wrong path is worse than none. +const identitySetArg = (key: string, value: string): string => + `shell_environment_policy.set.${key}='${value}'` export function getPosixCodexShellLaunchPreflight(): string { return `# Why: a typed alias expands inside the shell, after pane launch prep. @@ -17,13 +41,19 @@ if [[ -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then # which otherwise rewrites this header at parse time and aborts the whole file. function codex { # Why local: zsh's warn_create_global warns for each global a function creates. - local __orca_codex_arg __orca_codex_isolate="\${ORCA_CODEX_ISOLATE:-1}" + local __orca_codex_arg __orca_codex_isolate="\${ORCA_CODEX_ISOLATE:-1}" __orca_codex_identity="\${ORCA_CODEX_ISOLATE:-1}" if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : fi for __orca_codex_arg in "$@"; do - case "$__orca_codex_arg" in ${CODEX_SHARED_SERVER_ARGS.join('|')}|--remote=*) __orca_codex_isolate=0 ;; esac + case "$__orca_codex_arg" in + --no-daemon) __orca_codex_isolate=0 ;; + ${CODEX_SHARED_SERVER_ARGS.join('|')}|--remote=*) __orca_codex_isolate=0 __orca_codex_identity=0 ;; + esac done + if [[ "$__orca_codex_identity" != 0 ]]; then +${CODEX_COMMAND_IDENTITY_ENV_KEYS.map((key) => ` [[ -z "\${${key}:-}" || "\${${key}}" == *"'"* ]] || set -- -c "${identitySetArg(key, `\${${key}}`)}" "$@"`).join('\n')} + fi # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. if [[ "$__orca_codex_isolate" != 0 ]]; then case "$(command codex --help 2>/dev/null /dev/null 2>&1; or true end - if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '${CODEX_SHARED_SERVER_ARG_PATTERN}' $argv; and command codex --help 2>/dev/null ` test -n "$${key}"; and not string match -q -- "*'*" "$${key}"; and set argv -c "${identitySetArg(key, `$${key}`)}" $argv`).join('\n')} + end + if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '${CODEX_NO_DAEMON_SKIP_ARG_PATTERN}' $argv; and command codex --help 2>/dev/null $null) -match '--no-daemon') { $orcaCodexFlags = @('--no-daemon') @@ -81,7 +116,11 @@ if ($orcaCodexCommand -and } catch { } } - $orcaCodexArgs = @(__OrcaEscapeNativeArgs $orcaCodexExecutable.Source $args) + $orcaCodexIdentity = @() + if ($env:ORCA_CODEX_ISOLATE -ne '0' -and -not (@($args) -cmatch '${CODEX_SHARED_SERVER_ARG_PATTERN}')) { + foreach ($k in ${CODEX_COMMAND_IDENTITY_ENV_KEYS.map((key) => `'${key}'`).join(',')}) { $v = [Environment]::GetEnvironmentVariable($k); if ($v -and -not $v.Contains("'")) { $orcaCodexIdentity += '-c', "${identitySetArg('$k', '$v')}" } } + } + $orcaCodexArgs = @(__OrcaEscapeNativeArgs $orcaCodexExecutable.Source (@($orcaCodexIdentity) + @($args))) # Why: a native command inside a function never sees the function's pipeline input on its own. if ($MyInvocation.ExpectingInput) { $input | & $orcaCodexExecutable.Source @orcaCodexFlags @orcaCodexArgs diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index b5e8b56943d..43c7fcfc57b 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 49, + "daemonProtocolVersion": 50, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 6d9ac029bb8..8057c06237d 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 49, + daemonProtocolVersion: 50, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50 ] } as const