From 9420d49bcb799ca1c684a8ed24ff95a8cdcd1f6c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:22:27 -0400 Subject: [PATCH] fix(terminal): run Codex in Orca terminals without the shared background server (#23900) --- .github/workflows/pr.yml | 21 ++ ...odex-index-heal-contract-workflow.test.mjs | 17 + config/scripts/pr-code-change-scope.mjs | 5 +- src/cli/codex-command-classification.test.ts | 5 + src/cli/codex-command-classification.ts | 1 + .../daemon-bash-rcfile.txt | 15 +- .../daemon-fish-init.txt | 9 +- .../daemon-zsh-zshenv.txt | 41 +- .../local-bash-rcfile.txt | 15 +- .../local-fish-init.txt | 9 +- .../local-zsh-zshenv.txt | 41 +- .../relay-bash-rcfile.txt | 26 ++ .../relay-zsh-zshenv.txt | 26 ++ .../daemon-zsh-shell-ready-wrapper-spec.ts | 3 +- .../pty-subprocess/shell-launch-plan.ts | 3 +- src/main/daemon/shell-ready.ts | 14 +- .../local-pty-shell-ready-wrapper-fileset.ts | 3 +- src/main/providers/windows-shell-args.test.ts | 25 ++ src/main/providers/windows-shell-args.ts | 10 +- .../pty/codex-launch-shell-wrapping.test.ts | 80 ++++ .../codex-no-daemon-binary-contract.test.ts | 75 ++++ .../pty/codex-shell-launch-preflight.test.ts | 5 +- src/main/pty/codex-shell-launch-preflight.ts | 58 ++- src/main/pty/codex-shell-no-daemon.test.ts | 353 ++++++++++++++++++ src/main/pty/wsl-orca-env.ts | 1 + src/main/zsh-startup-wrapper-builder.ts | 7 +- .../pty-handler-spawn-environment.test.ts | 27 ++ src/relay/pty-handler.ts | 2 + src/relay/pty-shell-overlay-wrappers.ts | 6 +- 29 files changed, 830 insertions(+), 73 deletions(-) create mode 100644 src/main/pty/codex-launch-shell-wrapping.test.ts create mode 100644 src/main/pty/codex-no-daemon-binary-contract.test.ts create mode 100644 src/main/pty/codex-shell-no-daemon.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 795beca131e..c961f525a38 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -439,6 +439,8 @@ jobs: runs-on: ubuntu-24.04-arm env: CODEX_CLI_VERSION: '0.150.1' + # Why a second pin: --no-daemon only exists from 0.156, and Orca's codex wrapper relies on it. + CODEX_NO_DAEMON_CLI_VERSION: '0.158.0' steps: - name: Checkout @@ -467,6 +469,22 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/codex/codex-index-heal-binary-contract.test.ts + - name: Install pinned no-daemon Codex CLI + run: | + set -euo pipefail + npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli-no-daemon" \ + "@openai/codex@$CODEX_NO_DAEMON_CLI_VERSION" + + - name: Verify Codex --no-daemon contract + env: + ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED: '1' + ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION: ${{ env.CODEX_NO_DAEMON_CLI_VERSION }} + run: | + set -euo pipefail + ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/pty/codex-no-daemon-binary-contract.test.ts + xterm_patch_sync: name: xterm patch sync needs: [code_paths] @@ -513,6 +531,8 @@ jobs: # and its fish lane is the only end-to-end guard for #9993, so a skip would # report green with nothing exercised. Turns those skips into failures. ORCA_REQUIRE_FISH: '1' + # Why: the runner image ships pwsh, and the codex wrapper's PowerShell lane must not skip. + ORCA_REQUIRE_PWSH: '1' steps: - name: Checkout @@ -607,6 +627,7 @@ jobs: src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/codex-shell-launch-preflight.test.ts \ + src/main/pty/codex-shell-no-daemon.test.ts \ src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ diff --git a/config/scripts/codex-index-heal-contract-workflow.test.mjs b/config/scripts/codex-index-heal-contract-workflow.test.mjs index 62c9787a12e..096c4b876a8 100644 --- a/config/scripts/codex-index-heal-contract-workflow.test.mjs +++ b/config/scripts/codex-index-heal-contract-workflow.test.mjs @@ -34,4 +34,21 @@ describe('Codex index-heal contract PR gate', () => { 'set -euo pipefail' ) }) + + it('pins the --no-daemon contract to one Codex version and fails when it is missing', () => { + const install = job.steps.find((step) => step.name === 'Install pinned no-daemon Codex CLI') + const verify = job.steps.find((step) => step.name === 'Verify Codex --no-daemon contract') + + expect(job.env.CODEX_NO_DAEMON_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/) + expect(install.run).toContain('"@openai/codex@$CODEX_NO_DAEMON_CLI_VERSION"') + expect(verify.env.ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION).toBe( + '${{ env.CODEX_NO_DAEMON_CLI_VERSION }}' + ) + expect(verify.env.ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED).toBe('1') + expect(install.run).toContain('--prefix "$RUNNER_TEMP/codex-cli-no-daemon"') + expect(verify.run).toContain( + 'ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex"' + ) + expect(verify.run).toContain('src/main/pty/codex-no-daemon-binary-contract.test.ts') + }) }) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 98dc914052a..9fef4ed19a0 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -53,8 +53,11 @@ const GIT_COMPAT_PREFIXES = [ ] // Why narrow: the contract pins Codex's read-repair, so it runs when the heal that -// depends on it, its app-server transport, or the contract itself changes. +// depends on it, its app-server transport, or the contract itself changes. The same +// job pins --no-daemon for Orca's codex shell wrapper. const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ + 'src/main/pty/codex-no-daemon-binary-contract', + 'src/main/pty/codex-shell-launch-preflight', 'src/main/codex/codex-index-heal-binary-contract', 'src/main/codex/codex-session-index-heal', 'src/main/codex/codex-app-server-session', diff --git a/src/cli/codex-command-classification.test.ts b/src/cli/codex-command-classification.test.ts index ce92d15ee65..ec178efa55e 100644 --- a/src/cli/codex-command-classification.test.ts +++ b/src/cli/codex-command-classification.test.ts @@ -38,6 +38,11 @@ describe('shouldUseRendererBackedCodexTerminal', () => { expect(shouldUseRendererBackedCodexTerminal('codex --help')).toBe(false) }) + it('reads --no-daemon as a flag, not the subcommand', () => { + expect(shouldUseRendererBackedCodexTerminal('codex --no-daemon resume --last')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex --no-daemon exec summarize')).toBe(false) + }) + it('ignores non-Codex commands', () => { expect(shouldUseRendererBackedCodexTerminal(undefined)).toBe(false) expect(shouldUseRendererBackedCodexTerminal('claude')).toBe(false) diff --git a/src/cli/codex-command-classification.ts b/src/cli/codex-command-classification.ts index 941b02c24f6..b68efdcb60d 100644 --- a/src/cli/codex-command-classification.ts +++ b/src/cli/codex-command-classification.ts @@ -62,6 +62,7 @@ const CODEX_GLOBAL_BOOLEAN_FLAGS = new Set([ '--dangerously-bypass-approvals-and-sandbox', '--search', '--no-alt-screen', + '--no-daemon', '--help', '-h', '--version', 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 536831230b4..bfbd9b420e2 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -104,11 +104,22 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { - "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + # 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}" + 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 + done + # 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) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file +if test "$__orca_codex_type" = file function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT" + command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/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 2>&1 || : - command codex "$@" - } - fi - unset __orca_codex_binary fi + # Why: a typed alias expands inside the shell, after pane launch prep. + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" + if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + # Why the function reserved word: it suppresses alias expansion of the name, + # 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}" + 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 + done + # 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 || : + # 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}" + 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 + done + # 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) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file +if test "$__orca_codex_type" = file function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT" + command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/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 2>&1 || : - command codex "$@" - } - fi - unset __orca_codex_binary fi + # Why: a typed alias expands inside the shell, after pane launch prep. + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" + if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + # Why the function reserved word: it suppresses alias expansion of the name, + # 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}" + 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 + done + # 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 + done + # 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 + done + # 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 { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) + it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { + const plain = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice' + ) + const launched = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + "codex 'fix the bug'" + ) + + expect(plain.shellArgs).toEqual([ + '-c', + 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' + ]) + // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. + expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( + 'set -- --no-daemon "$@"' + ) + expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('quotes a spaced preflight path through each shell environment', () => { const cmd = resolveWindowsShellLaunchArgs( 'cmd.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 70fd22a06ad..c5ceade00a4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,8 +29,9 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string { - if (!codexLaunchPreflightCommand) { +// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. +function getGitBashLaunchCommand(useWrapper: boolean): string { + if (!useWrapper) { return GIT_BASH_UTF8_LOGIN_COMMAND } @@ -216,7 +217,10 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['-c', getGitBashLaunchCommand(codexLaunchPreflightCommand)], + shellArgs: [ + '-c', + getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) + ], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/pty/codex-launch-shell-wrapping.test.ts b/src/main/pty/codex-launch-shell-wrapping.test.ts new file mode 100644 index 00000000000..02d6c223a2f --- /dev/null +++ b/src/main/pty/codex-launch-shell-wrapping.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { selectShellStartupFeatures } from '../shell-startup-features' + +// Why: the codex --no-daemon wrapper only reaches shells Orca wraps. Orca's own +// Codex launches carry a startup command, so each launch shell must be wrapped. +const COMMAND = "codex 'fix the bug'" +const NO_DAEMON = 'set -- --no-daemon "$@"' +const FISH_NO_DAEMON = 'set argv --no-daemon $argv' + +function codexLaunchFeatures(shellPath: string) { + // Why an empty env: a system-default Codex home, with hooks off, carries no overlay key. + const waitsForShellReady = shouldUseShellReadyStartupDelivery({ command: COMMAND, shellPath }) + return selectShellStartupFeatures({ + shellPath, + env: {}, + hasStartupCommand: true, + waitsForShellReady, + emitsStartupIdentity: waitsForShellReady + }) +} + +function wrapperText(config: { args: string[] | null; env: Record }): string { + if (config.env.ZDOTDIR) { + return readFileSync(join(config.env.ZDOTDIR, '.zshenv'), 'utf8') + } + const rcfile = config.args?.[config.args.indexOf('--rcfile') + 1] + return rcfile && config.args?.includes('--rcfile') + ? readFileSync(rcfile, 'utf8') + : (config.args ?? []).join('\n') +} + +describe.skipIf(process.platform === 'win32')('Orca Codex launch shells carry the wrapper', () => { + let userData: string + const original = process.env.ORCA_USER_DATA_PATH + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-codex-launch-wrap-')) + process.env.ORCA_USER_DATA_PATH = userData + vi.resetModules() + }) + + afterEach(() => { + if (original === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = original + } + rmSync(userData, { recursive: true, force: true }) + }) + + it.each([ + ['/bin/bash', NO_DAEMON], + ['/bin/zsh', NO_DAEMON], + ['/usr/bin/fish', FISH_NO_DAEMON] + ])('daemon transport wraps %s', async (shell, marker) => { + const { getShellLaunchConfig } = await import('../daemon/shell-ready') + + expect( + wrapperText( + getShellLaunchConfig(shell, codexLaunchFeatures(shell), { hasStartupCommand: true }) + ) + ).toContain(marker) + }) + + it.each([ + ['/bin/bash', NO_DAEMON], + ['/bin/zsh', NO_DAEMON], + ['/usr/bin/fish', FISH_NO_DAEMON] + ])('local transport wraps %s', async (shell, marker) => { + const { getShellLaunchConfig } = await import('../providers/local-pty-shell-ready') + + expect(wrapperText(getShellLaunchConfig(shell, codexLaunchFeatures(shell), COMMAND))).toContain( + marker + ) + }) +}) diff --git a/src/main/pty/codex-no-daemon-binary-contract.test.ts b/src/main/pty/codex-no-daemon-binary-contract.test.ts new file mode 100644 index 00000000000..e02301fa6a2 --- /dev/null +++ b/src/main/pty/codex-no-daemon-binary-contract.test.ts @@ -0,0 +1,75 @@ +import { execFile } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +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 './codex-shell-launch-preflight' + +// Why: Orca's codex shell wrapper puts --no-daemon first for every subcommand but +// agents/queue (codex-shell-launch-preflight.ts). Its tests use a fake codex, so +// only the real binary can catch a renamed flag or a new subcommand rejecting it. + +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 TIMEOUT_MS = 30_000 + +describe.runIf(process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED === '1' && !binary)( + 'codex --no-daemon contract prerequisites', + () => { + it('was given a Codex binary to run against', () => { + expect.fail('ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED=1 but no binary was given') + }) + } +) + +describe.runIf(binary)('codex --no-daemon binary contract', { timeout: 120_000 }, () => { + let home: string + let help: string + + beforeAll(async () => { + // Why a disposable home: never read or start anything under the user's ~/.codex. + home = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-contract-')) + const version = await run(['--version']) + expect(version.stdout.trim()).toBe(`codex-cli ${expectedVersion}`) + help = (await run(['--help'])).stdout + }) + + afterAll(() => { + rmSync(home, { recursive: true, force: true }) + }) + + function run(args: string[]): Promise<{ stdout: string; stderr: string }> { + return execFileAsync(binary!, args, { + timeout: TIMEOUT_MS, + env: { ...process.env, CODEX_HOME: home } + }) + } + + function listedSubcommands(): string[] { + const section = help.split(/^Commands:\n/m)[1]?.split(/\n\s*\n/)[0] ?? '' + return [...section.matchAll(/^ {2}([a-z][a-z0-9-]*)\s/gm)].map((match) => match[1]) + } + + it('lists --no-daemon in --help, which is what the wrapper probes', () => { + expect(help).toContain('--no-daemon') + }) + + it('accepts --no-daemon first for every listed subcommand the wrapper does not skip', async () => { + const subcommands = listedSubcommands() + // Why a floor: a help layout change must fail here, not shrink the check to nothing. + expect(subcommands.length).toBeGreaterThan(20) + expect(subcommands).toEqual(expect.arrayContaining(['exec', 'resume', 'agents', 'queue'])) + const rejected: string[] = [] + for (const subcommand of subcommands.filter((name) => !WRAPPER_SKIPPED_SUBCOMMANDS.has(name))) { + // Why bare `help`: clap's help subcommand treats `--help` as a command name. + const args = subcommand === 'help' ? ['help'] : [subcommand, '--help'] + await run(['--no-daemon', ...args]).catch((error: { stderr?: string }) => + rejected.push(`${subcommand}: ${error.stderr?.trim() ?? String(error)}`) + ) + } + expect(rejected).toEqual([]) + }) +}) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index f0673fc9c95..3056deeac78 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -309,9 +309,8 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () // Regression for #16893: an unquoted `(type -t codex)` expands to zero words when // codex is absent, so `test` saw `= file` (2 args) and printed "Missing argument - // at index 3" on every fish pane launch. Needs a valid executable - // ORCA_CODEX_LAUNCH_PREFLIGHT so the `and` chain reaches the second `test`, and - // the real `-l -C` launch shape both shell-ready call sites use. + // at index 3" on every fish pane launch. Uses the real `-l -C` launch shape both + // shell-ready call sites use. it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => { const { bin, preflight } = createFishSandbox('orca-codex-fish-absent-') diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 87e5df75827..0fcd0c37c07 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -65,6 +65,11 @@ function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolea } } +// 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=.*)$` + export function getPosixCodexShellLaunchPreflight(): string { return `# Why: a typed alias expands inside the shell, after pane launch prep. # Why unalias inside the substitution: an alias named codex makes command -v @@ -72,11 +77,22 @@ export function getPosixCodexShellLaunchPreflight(): string { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then +if [[ -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { - "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + # 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}" + 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 + done + # 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) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file +if test "$__orca_codex_type" = file function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT" + command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/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 $null - } catch { - } $orcaCodexExecutable = Get-Command codex -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $orcaCodexExecutable) { Write-Error "codex executable not found" $global:LASTEXITCODE = 127 return } - & $orcaCodexExecutable.Source @args + $orcaCodexFlags = @() + # Why try/catch: under the user's $ErrorActionPreference = 'Stop', a failing prep or probe must not abort the launch. + if ($env:ORCA_CODEX_LAUNCH_PREFLIGHT) { + try { + & $env:ORCA_CODEX_LAUNCH_PREFLIGHT agent hooks prepare-codex *> $null + } catch { + } + } + if ($env:ORCA_CODEX_ISOLATE -ne '0' -and -not (@($args) -cmatch '${CODEX_SHARED_SERVER_ARG_PATTERN}')) { + try { + if ((& $orcaCodexExecutable.Source --help 2>$null) -match '--no-daemon') { + $orcaCodexFlags = @('--no-daemon') + } + } catch { + } + } + # Why: a native command inside a function never sees the function's pipeline input on its own. + if ($MyInvocation.ExpectingInput) { + $input | & $orcaCodexExecutable.Source @orcaCodexFlags @args + } else { + & $orcaCodexExecutable.Source @orcaCodexFlags @args + } $global:LASTEXITCODE = $LASTEXITCODE } } diff --git a/src/main/pty/codex-shell-no-daemon.test.ts b/src/main/pty/codex-shell-no-daemon.test.ts new file mode 100644 index 00000000000..53f978e8360 --- /dev/null +++ b/src/main/pty/codex-shell-no-daemon.test.ts @@ -0,0 +1,353 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' +import { + getFishCodexShellLaunchPreflight, + getPosixCodexShellLaunchPreflight, + getPowerShellCodexShellLaunchPreflight +} from './codex-shell-launch-preflight' +import { resolveFishBinary } from '../../shared/fish-binary-requirement' + +const isWindows = process.platform === 'win32' +const fishLookup = resolveFishBinary() +const canRun = (command: string): boolean => + spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0 +const pwshAvailable = canRun('pwsh') + +const HELP_WITH_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-daemon Run in-process\n' +const HELP_WITHOUT_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-alt-screen\n' + +// Why argv as whole words: the rule is a whole-argument denylist (plan §4). +const ADDED: string[][] = [ + [], + ['fix the bug'], + ['exec the plan'], + ['-m', 'gpt-5', '--yolo', 'x'], + ['resume'], + ['resume', '--last'], + ['--yolo', 'resume', '--last'], + ['fork', '--last'], + ['-a', 'never', 'resume'], + ['-c', 'model=o3'], + ['archive', 'S'], + ['delete', 'S'], + ['exec', 'x'], + ['e', 'x'], + ['-m', 'x', 'exec', 'x'], + ['review'], + ['login'], + ['mcp', 'list'] +] +const UNCHANGED: string[][] = [ + ['agents'], + ['-m', 'x', 'agents'], + ['-c', 'k=v', 'agents'], + ['--image=a.png', 'agents'], + ['agents', '--remote', 'X'], + ['queue', '--thread', 'T', '--message', 'M'], + ['-c', 'k=v', 'queue'], + ['--no-daemon'], + ['resume', '--no-daemon'], + ['-m', 'x', '--no-daemon'], + ['--remote', 'unix://'], + ['--remote=ws://h:1'], + ['resume', '--remote', 'X'], + ['-m', 'agents'], + ['--', 'agents'], + ['--', '--remote'] +] +const ALL = [...ADDED, ...UNCHANGED] + +type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'powershell' +const isPowerShell = (shell: Shell): boolean => shell === 'pwsh' || shell === 'powershell' +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function writeExecutable(path: string, content: string): void { + writeFileSync(path, content) + chmodSync(path, 0o755) +} + +type Sandbox = { bin: string; codex: string; helpFile: string; helpLog: string } + +/** Fake codex: `--help` prints the help file and logs the probe; any other call prints its argv. */ +function makeSandbox(help: string): Sandbox { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + const helpFile = join(root, 'help.txt') + const helpLog = join(root, 'help.log') + writeFileSync(helpFile, help) + writeFileSync(helpLog, '') + if (isWindows) { + // Why a .cmd shim over node: the shape npm installs, and what Get-Command resolves. + writeFileSync( + join(bin, 'fake.js'), + `const fs = require('fs') +const a = process.argv.slice(2) +if (a.length === 1 && a[0] === '--help') { + fs.appendFileSync(${JSON.stringify(helpLog)}, 'help\\n') + process.stdout.write(fs.readFileSync(${JSON.stringify(helpFile)}, 'utf8')) + process.exit(0) +} +let out = ['ARGV', ...a].join('|') +if (process.env.FAKE_CODEX_READ_STDIN === '1') out += '|stdin=' + fs.readFileSync(0, 'utf8').trim() +console.log(out) +process.exit(Number(process.env.FAKE_CODEX_EXIT || 0)) +` + ) + const codex = join(bin, 'codex.cmd') + writeFileSync(codex, '@node "%~dp0fake.js" %*\r\n') + return { bin, codex, helpFile, helpLog } + } + const codex = join(bin, 'codex') + writeExecutable( + codex, + `#!/bin/sh +if [ "$#" -eq 1 ] && [ "$1" = --help ]; then echo help >> ${JSON.stringify(helpLog)}; cat ${JSON.stringify(helpFile)}; exit 0; fi +out=ARGV +for a in "$@"; do out="$out|$a"; done +[ "\${FAKE_CODEX_READ_STDIN:-}" = 1 ] && out="$out|stdin=$(cat)" +printf '%s\\n' "$out" +exit "\${FAKE_CODEX_EXIT:-0}" +` + ) + return { bin, codex, helpFile, helpLog } +} + +function helpProbes(sandbox: Sandbox): number { + return readFileSync(sandbox.helpLog, 'utf8').split('\n').filter(Boolean).length +} + +function quote(shell: Shell, word: string): string { + if (isPowerShell(shell)) { + return `'${word.replace(/'/g, "''")}'` + } + if (shell === 'fish') { + return `'${word.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + } + return `'${word.replace(/'/g, `'\\''`)}'` +} + +function codexCall(shell: Shell, argv: string[]): string { + return ['codex', ...argv.map((word) => quote(shell, word))].join(' ') +} + +function run( + shell: Shell, + script: string, + sandbox: Sandbox, + env: Record = {}, + preamble = '' +): { status: number | null; stdout: string; stderr: string } { + const template = + shell === 'fish' + ? getFishCodexShellLaunchPreflight() + : isPowerShell(shell) + ? getPowerShellCodexShellLaunchPreflight() + : getPosixCodexShellLaunchPreflight() + // Why the guard: rows include `login`, which a real codex would run against the host's account. + const guard = isPowerShell(shell) + ? `if ((Get-Command codex -CommandType Application | Select-Object -First 1).Source -ne ${quote(shell, sandbox.codex)}) { exit 97 }` + : shell === 'fish' + ? `test (command -s codex) = ${quote(shell, sandbox.codex)}; or exit 97` + : `[ "$(command -v codex)" = ${quote(shell, sandbox.codex)} ] || exit 97` + const body = `${guard}\n${preamble}\n${template}\n${script}` + // Why a file for bash/zsh: it is read line by line like a startup file, so an alias it defines applies. + const scriptFile = join(sandbox.bin, '..', 'script.sh') + writeFileSync(scriptFile, body) + const [command, args]: [string, string[]] = + shell === 'bash' + ? ['/bin/bash', ['--noprofile', '--norc', scriptFile]] + : shell === 'zsh' + ? ['/bin/zsh', ['-f', scriptFile]] + : shell === 'fish' + ? [String(fishLookup.path), ['--no-config', '-c', body]] + : [shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', body]] + const result = spawnSync(command, args, { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${sandbox.bin}${delimiter}${process.env.PATH ?? ''}`, + CODEX_HOME: join(sandbox.bin, '..', 'codex-home'), + ...env + } + }) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } +} + +function lines(output: string): string[] { + return output.trimEnd().split(/\r?\n/) +} + +function expectedLine(argv: string[], added: boolean): string { + return ['ARGV', ...(added ? ['--no-daemon'] : []), ...argv].join('|') +} + +const shells: [Shell, boolean][] = [ + ['bash', !isWindows && existsSync('/bin/bash')], + ['zsh', !isWindows && existsSync('/bin/zsh')], + ['fish', fishLookup.available], + ['pwsh', pwshAvailable], + // Why: Windows PowerShell 5.1 turns redirected native stderr into errors, unlike pwsh. + ['powershell', isWindows && canRun('powershell')] +] + +describe('codex wrapper --no-daemon rule', () => { + it('has pwsh when CI demanded it', () => { + expect(process.env.ORCA_REQUIRE_PWSH !== '1' || pwshAvailable).toBe(true) + }) + + for (const [shell, available] of shells) { + describe.skipIf(!available)(shell, () => { + it('adds --no-daemon first unless an argument is denylisted', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run(shell, ALL.map((argv) => codexCall(shell, argv)).join('\n'), sandbox) + + expect(result.stderr).toBe('') + expect(lines(result.stdout)).toEqual([ + ...ADDED.map((argv) => expectedLine(argv, true)), + ...UNCHANGED.map((argv) => expectedLine(argv, false)) + ]) + // Why: a denylisted launch must not pay for, or depend on, the --help probe. + expect(helpProbes(sandbox)).toBe(ADDED.length) + }) + + it('adds nothing when --help does not list the flag (0.155 and older)', () => { + const sandbox = makeSandbox(HELP_WITHOUT_FLAG) + const result = run(shell, ALL.map((argv) => codexCall(shell, argv)).join('\n'), sandbox) + + expect(lines(result.stdout)).toEqual(ALL.map((argv) => expectedLine(argv, false))) + }) + + it('adds nothing with ORCA_CODEX_ISOLATE=0, read on every call', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const setIsolate = (value: string): string => + isPowerShell(shell) + ? `$env:ORCA_CODEX_ISOLATE = '${value}'` + : shell === 'fish' + ? `set -gx ORCA_CODEX_ISOLATE ${value}` + : `export ORCA_CODEX_ISOLATE=${value}` + const result = run( + shell, + ['codex a', setIsolate('1'), 'codex b', setIsolate('0'), 'codex c'].join('\n'), + sandbox, + { ORCA_CODEX_ISOLATE: '0' } + ) + + expect(lines(result.stdout)).toEqual(['ARGV|a', 'ARGV|--no-daemon|b', 'ARGV|c']) + }) + + it('re-probes --help when Codex changes version mid-shell', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const swap = (help: string): string => + isPowerShell(shell) + ? `Set-Content -NoNewline -LiteralPath ${quote(shell, sandbox.helpFile)} -Value ${quote(shell, help)}` + : `printf '%s' ${quote(shell, help)} > ${quote(shell, sandbox.helpFile)}` + const result = run( + shell, + ['codex a', swap(HELP_WITHOUT_FLAG), 'codex b', swap(HELP_WITH_FLAG), 'codex c'].join( + '\n' + ), + sandbox + ) + + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|a', 'ARGV|b', 'ARGV|--no-daemon|c']) + }) + + it("keeps piped stdin for Codex and returns Codex's exit status", () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const script = isPowerShell(shell) + ? `'piped' | codex exec -\n"status=$LASTEXITCODE"` + : shell === 'fish' + ? `printf piped | codex exec -\necho status=$status` + : `printf piped | codex exec -\necho status=$?` + const result = run(shell, script, sandbox, { + FAKE_CODEX_READ_STDIN: '1', + FAKE_CODEX_EXIT: '3' + }) + + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|exec|-|stdin=piped', 'status=3']) + }) + }) + } + + for (const [shell, enableAliases] of [ + ['bash', 'shopt -s expand_aliases'], + ['zsh', 'setopt aliases'] + ] as const) { + it.skipIf(isWindows || !existsSync(`/bin/${shell}`))( + `applies a user alias named codex defined before the wrapper in ${shell}`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run( + shell, + 'codex x', + sandbox, + {}, + `${enableAliases}\nalias codex='codex --alias-flag'` + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('ARGV|--no-daemon|--alias-flag|x') + } + ) + } + + it.skipIf(isWindows || !existsSync('/bin/zsh'))( + 'creates no globals under warn_create_global', + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run('zsh', 'setopt warn_create_global no_unset\ncodex x', sandbox) + + expect(result.stderr).toBe('') + expect(result.stdout.trim()).toBe('ARGV|--no-daemon|x') + } + ) + + for (const [shell, available] of shells.filter(([name]) => isPowerShell(name))) { + it.skipIf(!available)( + `${shell} runs under StrictMode and Stop with a failing, noisy hook prep`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const prep = join(sandbox.bin, isWindows ? 'orca-prep.cmd' : 'orca-prep') + writeExecutable( + prep, + isWindows + ? '@echo prep-noise 1>&2\r\n@exit /b 7\r\n' + : '#!/bin/sh\necho prep-noise >&2\nexit 7\n' + ) + const result = run( + shell, + 'codex x\n"status=$LASTEXITCODE"', + sandbox, + { ORCA_CODEX_LAUNCH_PREFLIGHT: prep }, + [ + 'Set-StrictMode -Version Latest', + '$ErrorActionPreference = "Stop"', + '$PSNativeCommandUseErrorActionPreference = $true' + ].join('\n') + ) + + expect(result.status, result.stderr).toBe(0) + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|x', 'status=0']) + } + ) + } +}) diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index b3047fd8a27..0044399009f 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -82,6 +82,7 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why /p: the managed CLI launcher lives in the host's userData tree. 'ORCA_WSL_CLI_DIR/p', 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', + 'ORCA_CODEX_ISOLATE/u', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 49f706e5741..12a92b6bb45 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -47,8 +47,6 @@ export type ZshWrapperRestoreSpec = { remoteCliBinDir: boolean /** Orca's runtime CODEX_HOME. */ codexHome: boolean - /** The `codex()` wrapper that runs Orca's launch preflight. */ - codexLaunchPreflight: boolean } export type ZshStartupHookSpec = { @@ -127,8 +125,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { MIMOCODE_HOME_RESTORE, spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null, getPosixOmpShellWrapper(), - spec.restores.codexHome ? CODEX_HOME_RESTORE : null, - spec.restores.codexLaunchPreflight ? getPosixCodexShellLaunchPreflight() : null + spec.restores.codexHome ? CODEX_HOME_RESTORE : null ] } @@ -175,6 +172,8 @@ ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, featureGuard('overlay', getOverlayRestoreBlocks(spec)), + // Why outside the overlay guard: a system-default Codex home carries no overlay key. + indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file // ran, so the value it derives is the user's own path. #11044 is unreachable. ` if [[ -n "\${_orca_histfile:-}" ]]; then diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index 22993322a9c..57f041c2b8f 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -336,6 +336,33 @@ describe('PtyHandler', () => { } ) + it.each(['process', 'client'])( + 'drops an ORCA_CODEX_LAUNCH_PREFLIGHT from the relay %s env', + async (source) => { + const inherited = '/opt/orca/bin/orca' + const previous = process.env.ORCA_CODEX_LAUNCH_PREFLIGHT + if (source === 'process') { + process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = inherited + } + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + ...(source === 'client' ? { env: { ORCA_CODEX_LAUNCH_PREFLIGHT: inherited } } : {}) + }) + } finally { + if (previous === undefined) { + delete process.env.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = previous + } + } + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + } + ) + it('drops an ORCA_HISTFILE handed over in the client env', async () => { await dispatcher.callRequest('pty.spawn', { cols: 80, diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 286bfdb89a7..d739bcd97ee 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -845,6 +845,8 @@ export class PtyHandler { // pane to another worktree's history file — and wrapping a zsh pane that // nothing asked to wrap, since `history` is selected on its presence. delete result.ORCA_HISTFILE + // Why: the codex wrapper runs this path as hook prep, and a relay pane never gets one of its own. + delete result.ORCA_CODEX_LAUNCH_PREFLIGHT // Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values. for (const key of envToDelete) { delete result[key] diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 2231fc2b29b..55251883661 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { getPosixCodexShellLaunchPreflight } from '../main/pty/codex-shell-launch-preflight' import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, @@ -35,8 +36,7 @@ function getRelayZshWrapperSpec(): ZshStartupHookSpec { managedWslCli: false, agentTeamsPath: false, remoteCliBinDir: true, - codexHome: false, - codexLaunchPreflight: false + codexHome: false } } } @@ -74,7 +74,7 @@ fi [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} -${BASH_HISTFILE_RESTORE_BLOCK} +${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_initializing_wrapper=1