mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,8 +68,16 @@ export async function isCommandOnLocalPath(
|
||||
command: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): Promise<boolean> {
|
||||
return (await resolveCommandOnLocalPath(command, options)) !== null
|
||||
}
|
||||
|
||||
/** The absolute path `isCommandOnLocalPath` found, or null. */
|
||||
export async function resolveCommandOnLocalPath(
|
||||
command: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): Promise<string | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<LocalCodexLaunch> = {}) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string> = 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<string, string | undefined> | 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<string> | 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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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)" in *--no-daemon*) set -- --no-daemon "$@" ;; esac
|
||||
fi
|
||||
command codex "$@"
|
||||
}
|
||||
fi
|
||||
unset __orca_codex_binary
|
||||
`
|
||||
}
|
||||
|
||||
export function getFishCodexShellLaunchPreflight(): string {
|
||||
return `# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/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 </dev/null | string match -q -- '*--no-daemon*'
|
||||
set argv --no-daemon $argv
|
||||
end
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type`
|
||||
}
|
||||
|
||||
export function getPowerShellCodexShellLaunchPreflight(): string {
|
||||
return `$orcaCodexCommand = Get-Command codex -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($orcaCodexCommand -and
|
||||
$orcaCodexCommand.CommandType -in @("Application", "ExternalScript")) {
|
||||
function Global:codex {
|
||||
$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
|
||||
}
|
||||
$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
|
||||
}
|
||||
}
|
||||
Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)" in *--no-daemon*) set -- --no-daemon "$@" ;; esac
|
||||
fi
|
||||
command codex "$@"
|
||||
}
|
||||
fi
|
||||
unset __orca_codex_binary
|
||||
`
|
||||
}
|
||||
|
||||
export function getFishCodexShellLaunchPreflight(): string {
|
||||
return `# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/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 </dev/null | string match -q -- '*--no-daemon*'
|
||||
set argv --no-daemon $argv
|
||||
end
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type`
|
||||
}
|
||||
|
||||
export function getPowerShellCodexShellLaunchPreflight(): string {
|
||||
return `$orcaCodexCommand = Get-Command codex -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($orcaCodexCommand -and
|
||||
$orcaCodexCommand.CommandType -in @("Application", "ExternalScript")) {
|
||||
function Global:codex {
|
||||
$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
|
||||
}
|
||||
$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
|
||||
}
|
||||
}
|
||||
Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue`
|
||||
}
|
||||
@@ -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, string> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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;',
|
||||
|
||||
Reference in New Issue
Block a user