From 26bb7c23f19155b4a0eb192fd82d498724df2f13 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:40:53 -0400 Subject: [PATCH] fix(terminal): run Orca's cmd.exe, path-named and setup-gated Codex launches without the shared server (#23933) * fix(terminal): give plain shells and cmd.exe Codex launches --no-daemon Plain bash, zsh and fish tabs were never wrapped, so a typed codex skipped the shell function that adds --no-daemon. Wrap them (bash keeps its prompt and DEBUG trap untouched unless Orca asked for command markers), add --no-daemon host-side where no function can run (cmd.exe, path-named binaries), and move new tabs to a v38 terminal daemon so they get the new wrappers. * fix(terminal): keep plain bash a login shell and give the setup gate the codex function Plain bash and Git Bash tabs launch exactly as before again: the rcfile wrapper would have made every one a non-login shell. Plain tabs on the user's configured shell args stay unwrapped on both transports. The wait-for-setup gate's bash -lc now defines the codex function, so a sequenced Codex launch gets --no-daemon from the binary it actually runs. * fix(terminal): define the setup gate's codex function after setup finishes Setup can be what puts codex on PATH, so defining the function before the marker wait found no binary and skipped --no-daemon. * refactor(terminal): fold the SSH/WSL guard into the Codex launch planner and bound the gate test * fix(terminal): honour the pane's env deletions in the Codex opt-out check Also pin the setup-gate test's fake codex ahead of path_helper's PATH. * revert(terminal): launch plain zsh and fish tabs exactly as on main Drops the always-wrap for plain zsh and fish, the configured-args guard that only served it, and the v38 daemon bump: the daemon's launch configs and generated wrappers are byte-identical to main again. Keeps the host-side --no-daemon for cmd.exe and path-named launches and the setup gate's codex function. --- config/scripts/pr-code-change-scope.mjs | 1 + .../daemon/daemon-bash-shell-ready-rcfile.ts | 2 +- src/main/daemon/shell-ready.ts | 2 +- src/main/ipc/command-path-resolver.ts | 14 ++- src/main/ipc/pty/ipc/spawn-options.ts | 14 ++- src/main/ipc/pty/runtime/spawn-options.ts | 14 ++- src/main/powershell-osc133-bootstrap.ts | 2 +- .../local-pty-shell-ready-bash-rcfile.ts | 2 +- src/main/providers/local-pty-shell-ready.ts | 2 +- .../codex-no-daemon-binary-contract.test.ts | 4 +- .../codex-no-daemon-launch-command.test.ts | 102 ++++++++++++++++++ .../pty/codex-no-daemon-launch-command.ts | 99 +++++++++++++++++ .../pty/codex-shell-launch-preflight.test.ts | 6 +- src/main/pty/codex-shell-launch-preflight.ts | 93 ---------------- src/main/pty/codex-shell-no-daemon.test.ts | 2 +- src/main/zsh-startup-wrapper-builder.ts | 2 +- src/relay/pty-shell-overlay-wrappers.ts | 2 +- src/shared/codex-shell-function.ts | 94 ++++++++++++++++ ...p-agent-sequencing-codex-no-daemon.test.ts | 90 ++++++++++++++++ src/shared/setup-agent-sequencing.ts | 5 +- 20 files changed, 438 insertions(+), 114 deletions(-) create mode 100644 src/main/pty/codex-no-daemon-launch-command.test.ts create mode 100644 src/main/pty/codex-no-daemon-launch-command.ts create mode 100644 src/shared/codex-shell-function.ts create mode 100644 src/shared/setup-agent-sequencing-codex-no-daemon.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 1aba99b1169..40ef234fd49 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -60,6 +60,7 @@ const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ 'src/main/codex/config-toml-trust', 'src/main/pty/codex-no-daemon-binary-contract', 'src/main/pty/codex-shell-launch-preflight', + 'src/shared/codex-shell-function', '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/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index f7834dba9d7..76f05e9ddf5 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,5 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' -import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 1f0a84b3a0c..a1065ba753c 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -6,7 +6,7 @@ import { getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' -import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { getFishShellReadyInitCommand } from '../shell-templates' import { encodeShellStartupFeatures, diff --git a/src/main/ipc/command-path-resolver.ts b/src/main/ipc/command-path-resolver.ts index e2fe49747e0..1f2934ba8b2 100644 --- a/src/main/ipc/command-path-resolver.ts +++ b/src/main/ipc/command-path-resolver.ts @@ -68,8 +68,16 @@ export async function isCommandOnLocalPath( command: string, options: ResolveCommandOptions = {} ): Promise { + return (await resolveCommandOnLocalPath(command, options)) !== null +} + +/** The absolute path `isCommandOnLocalPath` found, or null. */ +export async function resolveCommandOnLocalPath( + command: string, + options: ResolveCommandOptions = {} +): Promise { if (!command) { - return false + return null } const platform = options.platform ?? process.platform const env = options.env ?? process.env @@ -98,9 +106,9 @@ export async function isCommandOnLocalPath( continue } if (await isExecutableFile(candidate, isWin)) { - return true + return candidate } } } - return false + return null } diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 4d0d47eaf2e..00d0e3e2d8d 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -23,6 +23,7 @@ import { ptySizes } from '../delivery/visibility-state' import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' +import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' @@ -78,8 +79,17 @@ export async function buildPtyIpcSpawnOptions( if (ctx.combinedEnvToDelete) { ctx.spawnOptions.envToDelete = ctx.combinedEnvToDelete } - if (ctx.launchCommand !== undefined) { - ctx.spawnOptions.command = ctx.launchCommand + const noDaemonLaunch = planCodexNoDaemonLaunch({ + command: ctx.launchCommand, + executesOnThisHost: !args.connectionId && ctx.codexSelectionTarget.runtime !== 'wsl', + shellOverride: ctx.effectiveShellOverride, + env: ctx.spawnEnv, + envToDelete: ctx.combinedEnvToDelete, + cwd: ctx.cwd + }) + const launchCommand = noDaemonLaunch ? await noDaemonLaunch : ctx.launchCommand + if (launchCommand !== undefined) { + ctx.spawnOptions.command = launchCommand } if (args.commandDelivery !== undefined) { ctx.spawnOptions.commandDelivery = args.commandDelivery diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 3b50025a556..cf71d6cb22a 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -23,6 +23,7 @@ import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' +import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import { resolveStablePaneOwner } from '../pane/stable-owner' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { @@ -99,8 +100,17 @@ export async function buildRuntimePtySpawnOptions( } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) - if (ctx.launchCommand !== undefined) { - ctx.spawnOptions.command = ctx.launchCommand + const noDaemonLaunch = planCodexNoDaemonLaunch({ + command: ctx.launchCommand, + executesOnThisHost: !args.connectionId && ctx.codexSelectionTarget.runtime !== 'wsl', + shellOverride: ctx.daemonShellOverride, + env: ctx.env, + envToDelete: ctx.spawnOptions.envToDelete, + cwd: ctx.cwd + }) + const launchCommand = noDaemonLaunch ? await noDaemonLaunch : ctx.launchCommand + if (launchCommand !== undefined) { + ctx.spawnOptions.command = launchCommand } if (args.commandDelivery !== undefined) { ctx.spawnOptions.commandDelivery = args.commandDelivery diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index f776521d498..0dd39fa2a67 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,5 +1,5 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' -import { getPowerShellCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' +import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' /** diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 877fdda3bcc..0bbcd0712ea 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -7,7 +7,7 @@ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' -import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { getBashStartupCommandPromptBlock } from '../pty/posix-shell-startup-command' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 61a04fdf024..5a0388aff9f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -10,7 +10,7 @@ import { getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' -import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' import { getFishShellReadyInitCommand } from '../shell-templates' import { 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 e02301fa6a2..1e0116f2688 100644 --- a/src/main/pty/codex-no-daemon-binary-contract.test.ts +++ b/src/main/pty/codex-no-daemon-binary-contract.test.ts @@ -4,10 +4,10 @@ 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' +import { CODEX_SHARED_SERVER_ARGS } from '../../shared/codex-shell-function' // 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 +// agents/queue (src/shared/codex-shell-function.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) diff --git a/src/main/pty/codex-no-daemon-launch-command.test.ts b/src/main/pty/codex-no-daemon-launch-command.test.ts new file mode 100644 index 00000000000..985f9a2131c --- /dev/null +++ b/src/main/pty/codex-no-daemon-launch-command.test.ts @@ -0,0 +1,102 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { planCodexNoDaemonLaunch, type LocalCodexLaunch } from './codex-no-daemon-launch-command' + +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' +const hostPlatform = process.platform + +describe.skipIf(hostPlatform === 'win32')('planCodexNoDaemonLaunch', () => { + let dir: string + let codex: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-launch-')) + codex = writeCodex('codex', HELP_WITH_FLAG) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) + }) + + function writeCodex(name: string, help: string): string { + const path = join(dir, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${help}'\n`) + chmodSync(path, 0o755) + return path + } + + function plan(command: string, overrides: Partial = {}) { + return planCodexNoDaemonLaunch({ + command, + executesOnThisHost: true, + shellOverride: undefined, + env: {}, + cwd: dir, + ...overrides + }) + } + + it('adds --no-daemon once, right after a path-named codex', async () => { + await expect(plan(`${codex} --yolo 'fix the bug'`)).resolves.toBe( + `${codex} --no-daemon --yolo 'fix the bug'` + ) + }) + + it.each([ + ['agents'], + ['queue --thread T'], + ['--no-daemon'], + ['resume --no-daemon'], + ['--remote unix://'], + ['--remote=ws://h:1'] + ])('leaves `codex %s` alone: it needs the shared server or has the flag', (args) => { + expect(plan(`${codex} ${args}`)).toBeNull() + }) + + it('leaves SSH and WSL launches to the codex function on that host', () => { + expect(plan(`${codex} --yolo`, { executesOnThisHost: false })).toBeNull() + }) + + it('ignores an inherited opt-out the pane deletes', async () => { + vi.stubEnv('ORCA_CODEX_ISOLATE', '0') + + await expect(plan(`${codex} --yolo`, { envToDelete: ['ORCA_CODEX_ISOLATE'] })).resolves.toBe( + `${codex} --no-daemon --yolo` + ) + }) + + it('honours ORCA_CODEX_ISOLATE=0 from the pane env', () => { + expect(plan(`${codex} --yolo`, { env: { ORCA_CODEX_ISOLATE: '0' } })).toBeNull() + }) + + it('keeps the command when the binary predates --no-daemon', async () => { + const oldCodex = writeCodex('0.155/codex', HELP_WITHOUT_FLAG) + + await expect(plan(`${oldCodex} --yolo`)).resolves.toBe(`${oldCodex} --yolo`) + }) + + it.each([['codex --yolo'], ['claude --yolo'], ['/opt/bin/codexx --yolo']])( + 'leaves `%s` to the shell function or to another agent', + (command) => { + expect(plan(command)).toBeNull() + } + ) + + it('probes a bare codex on PATH for cmd.exe, which has no codex function', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + writeCodex('codex.exe', HELP_WITH_FLAG) + + await expect( + plan('codex --yolo', { + shellOverride: 'cmd.exe', + env: { PATH: dir, PATHEXT: '.exe' } + }) + ).resolves.toBe('codex --no-daemon --yolo') + }) +}) diff --git a/src/main/pty/codex-no-daemon-launch-command.ts b/src/main/pty/codex-no-daemon-launch-command.ts new file mode 100644 index 00000000000..eec86055568 --- /dev/null +++ b/src/main/pty/codex-no-daemon-launch-command.ts @@ -0,0 +1,99 @@ +import { isAbsolute, win32 as pathWin32 } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +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' + +const CODEX_EXECUTABLE = /^codex(\.(exe|cmd|bat|ps1))?$/i +const SHARED_SERVER_ARGS: ReadonlySet = new Set(CODEX_SHARED_SERVER_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 + +export type LocalCodexLaunch = { + command: string | undefined + /** False for SSH and WSL spawns: their shell's codex function probes on that host. */ + executesOnThisHost: boolean + /** Shell the provider will launch; undefined means the platform default. */ + shellOverride: string | undefined + /** Env the PTY gets on top of this process's own. */ + env: Record | undefined + /** Keys the provider removes from that merged env. */ + envToDelete?: readonly string[] + cwd: string | undefined +} + +/** + * The launch command with `--no-daemon` after the Codex executable, applying the + * shell codex function's rule (src/shared/codex-shell-function.ts) where that + * function never runs: cmd.exe defines none, and a path-named binary bypasses it. + * Everywhere else the function probes the binary the shell itself resolves after + * the user's startup files, which main cannot see. Null (synchronously) when + * nothing applies: an extra await tick would reorder the pane-spawn reservation + * races the spawn handlers arbitrate right after this. + */ +export function planCodexNoDaemonLaunch(launch: LocalCodexLaunch): Promise | null { + const { command } = launch + if (!command || !launch.executesOnThisHost) { + return null + } + const shell = + resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: launch.shellOverride + }) ?? 'posix' + const parsed = tokenizeStartupCommand(command, shell) + const executableSpan = parsed.ok ? parsed.spans[0] : undefined + if (!parsed.ok || !executableSpan || executableSpan.divergesFromShell) { + return null + } + const [executable, ...args] = parsed.tokens + if ( + !CODEX_EXECUTABLE.test(pathWin32.basename(executable)) || + (shell !== 'cmd' && !isAbsolute(executable)) + ) { + return null + } + const env = { ...process.env, ...launch.env } + for (const key of launch.envToDelete ?? []) { + delete env[key] + } + if ( + env.ORCA_CODEX_ISOLATE === '0' || + args.some((arg) => SHARED_SERVER_ARGS.has(arg) || arg.startsWith('--remote=')) + ) { + return null + } + return supportsNoDaemon(executable, env, launch.cwd).then((supported) => + supported + ? `${command.slice(0, executableSpan.end)} --no-daemon${command.slice(executableSpan.end)}` + : command + ) +} + +// Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. +async function supportsNoDaemon( + executable: string, + env: NodeJS.ProcessEnv, + cwd: string | undefined +): Promise { + const program = isAbsolute(executable) + ? executable + : await resolveCommandOnLocalPath(executable, { env, cwd }) + if (!program) { + return false + } + try { + const help = await runProcess({ + program, + args: ['--help'], + cwd, + env, + timeoutMs: HELP_PROBE_TIMEOUT_MS + }) + return help.stdout.includes('--no-daemon') + } catch { + return false + } +} diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 3056deeac78..72fbc5c14a5 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -13,12 +13,12 @@ import { tmpdir } from 'node:os' import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' +import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight' import { getFishCodexShellLaunchPreflight, getPosixCodexShellLaunchPreflight, - getPowerShellCodexShellLaunchPreflight, - resolveCodexShellLaunchPreflightCommand -} from './codex-shell-launch-preflight' + getPowerShellCodexShellLaunchPreflight +} from '../../shared/codex-shell-function' import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement' const roots: string[] = [] diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 0fcd0c37c07..b6c17074d79 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -64,96 +64,3 @@ function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolea return false } } - -// 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 -# 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 ${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 "$__orca_codex_type" = file - function codex - 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 { - } - } - 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 - } -} -Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue` -} diff --git a/src/main/pty/codex-shell-no-daemon.test.ts b/src/main/pty/codex-shell-no-daemon.test.ts index 53f978e8360..25ee00a962c 100644 --- a/src/main/pty/codex-shell-no-daemon.test.ts +++ b/src/main/pty/codex-shell-no-daemon.test.ts @@ -15,7 +15,7 @@ import { getFishCodexShellLaunchPreflight, getPosixCodexShellLaunchPreflight, getPowerShellCodexShellLaunchPreflight -} from './codex-shell-launch-preflight' +} from '../../shared/codex-shell-function' import { resolveFishBinary } from '../../shared/fish-binary-requirement' const isWindows = process.platform === 'win32' diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 12a92b6bb45..4fb4f8d01cf 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -28,7 +28,7 @@ */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' -import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' +import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 55251883661..c8d699f36be 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,7 +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 { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, diff --git a/src/shared/codex-shell-function.ts b/src/shared/codex-shell-function.ts new file mode 100644 index 00000000000..c886917a007 --- /dev/null +++ b/src/shared/codex-shell-function.ts @@ -0,0 +1,94 @@ +// 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=.*)$` + +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 +# 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 ${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 "$__orca_codex_type" = file + function codex + 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 { + } + } + 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 + } +} +Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue` +} diff --git a/src/shared/setup-agent-sequencing-codex-no-daemon.test.ts b/src/shared/setup-agent-sequencing-codex-no-daemon.test.ts new file mode 100644 index 00000000000..7ef05ec847b --- /dev/null +++ b/src/shared/setup-agent-sequencing-codex-no-daemon.test.ts @@ -0,0 +1,90 @@ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createSequencedSetupAgentCommands } from './setup-agent-sequencing' + +// Why: the POSIX gate evals the agent in a fresh `bash -lc`, which never reads +// Orca's shell wrapper, so it must carry the codex --no-daemon rule itself. +const roots: string[] = [] +const gates: ChildProcess[] = [] + +afterEach(() => { + for (const gate of gates.splice(0)) { + gate.kill() + } + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function prepareGate(startupCommand: string) { + const root = mkdtempSync(join(tmpdir(), 'orca-gate-codex-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + // Why: `bash -l` reads /etc/profile, whose macOS path_helper moves the fixture bin + // behind /usr/local/bin and /opt/homebrew/bin; re-prepend it so a real codex never runs. + writeFileSync(join(root, '.bash_profile'), `export PATH=${JSON.stringify(bin)}:"$PATH"\n`) + const runner = join(root, 'setup-runner.sh') + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: runner, + startupCommand, + platform: 'posix', + nonce: 'n1', + // Why: a failed assertion must not leave a gate polling for the default two hours. + waitTimeoutSeconds: 5 + }) + return { + sequenced, + // Why HOME and CODEX_HOME: `bash -l` must read no real profile, and nothing may touch ~/.codex. + env: { HOME: root, CODEX_HOME: root, PATH: `${bin}:/usr/bin:/bin`, ...sequenced.startupEnv }, + finishSetup: (help: string) => { + const codex = join(bin, 'codex') + writeFileSync( + codex, + `#!/bin/sh\n[ "$1" = --help ] && { printf '%s\\n' '${help}'; exit 0; }\nprintf 'ARGV:%s\\n' "$*"\n` + ) + chmodSync(codex, 0o755) + writeFileSync(`${runner}.n1.done`, 'n1:0\n') + } + } +} + +function runGate(startupCommand: string, help: string, env: Record = {}): string { + const gate = prepareGate(startupCommand) + gate.finishSetup(help) + return spawnSync('bash', ['-c', gate.sequenced.startupCommand], { + encoding: 'utf8', + env: { ...gate.env, ...env } + }).stdout +} + +describe.skipIf(process.platform === 'win32')('sequenced setup gate runs codex', () => { + it('with --no-daemon when the binary supports it', () => { + expect(runGate('codex --yolo', '--no-daemon')).toBe('ARGV:--no-daemon --yolo\n') + }) + + it.each([ + ['an old binary', 'codex --yolo', '--no-alt-screen', {}], + ['a subcommand that needs the shared server', 'codex agents', '--no-daemon', {}], + ['the opt-out', 'codex --yolo', '--no-daemon', { ORCA_CODEX_ISOLATE: '0' }] + ])('unchanged for %s', (_case, command, help, env) => { + expect(runGate(command, help, env)).toBe(`ARGV:${command.slice('codex '.length)}\n`) + }) + + it('with --no-daemon when setup is what installs codex', async () => { + const gate = prepareGate('codex --yolo') + const child = spawn('bash', ['-c', gate.sequenced.startupCommand], { env: gate.env }) + gates.push(child) + let stdout = '' + child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString())) + const exited = new Promise((resolve) => child.on('close', resolve)) + // Why after spawn: the gate is already waiting when setup puts codex on PATH. + await new Promise((resolve) => setTimeout(resolve, 300)) + gate.finishSetup('--no-daemon') + await exited + expect(stdout).toBe('ARGV:--no-daemon --yolo\n') + }) +}) diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index a865b23f837..0adcbebf138 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,4 +1,5 @@ import { encodePowerShellCommand } from './powershell-command-encoding' +import { getPosixCodexShellLaunchPreflight } from './codex-shell-function' import { nativeWindowsPathToPosixShellPath, resolveSetupRunnerCommand, @@ -128,7 +129,9 @@ function buildPosixStartupScript( `rm -f ${marker} ${tmp} 2>/dev/null;`, // Why: failure and timeout announce themselves; a silent success left // "Waiting for setup..." as the pane's last line forever. - `if [ "$status" = "0" ]; then echo ${quotePosixArg(SETUP_COMPLETE_MESSAGE)} >&2; if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, + // Why here and not first: setup may be what puts codex on PATH, and this + // `bash -lc` never reads Orca's shell wrapper, so it defines the function itself. + `if [ "$status" = "0" ]; then echo ${quotePosixArg(SETUP_COMPLETE_MESSAGE)} >&2;\n${getPosixCodexShellLaunchPreflight()}if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, 'echo "Setup failed; skipping agent startup." >&2;', 'exit "${status:-1}";', 'fi;',