mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(windows): stop blocking cmd.exe agent launches on a Codex preflight CLI call
On Windows every shell except cmd.exe installs the Codex launch preflight as a
deferred wrapper around the `codex` command, so it costs nothing at shell start.
cmd.exe has no equivalent, so the preflight was spliced into its `/K` startup
chain ahead of the agent command:
cmd.exe /K "chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT
call %..%%ORCA_CODEX_LAUNCH_PREFLIGHT%%..% agent hooks
prepare-codex > nul 2>&1 & <agent command>"
That `call` runs the Orca CLI, which connects back into the running app over
IPC. Measured in-app it took 10.4s under spawn-time load (0.19s standalone), so
the agent could not start until it returned — pushing the tab-bar "+" launch
past its startup budget and failing intermittently. It also ran for every
cmd.exe pane, including plain terminals with no agent and no startup command.
The work itself is `codexHookService.install()` into a managed Codex home that
the main process already knows: local, idempotent, sub-millisecond. Do it in
buildPtyHostEnv where the preflight env is resolved, and drop the inline cmd
call and the ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE argv-quoting workaround it
needed. The deferred wrappers on POSIX/PowerShell/Git Bash are unchanged and
still cover a `codex` typed later in an already-open pane.
This commit is contained in:
@@ -231,12 +231,9 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"' })
|
||||
env: expect.objectContaining({ ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT })
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -297,10 +294,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause'
|
||||
import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group'
|
||||
import { readPtsName } from '../pty/node-pty-pts-name'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from '../providers/windows-shell-args'
|
||||
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
@@ -747,13 +744,6 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise<S
|
||||
}) ?? shellPath)
|
||||
: shellPath
|
||||
}
|
||||
if (
|
||||
pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' &&
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
) {
|
||||
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
|
||||
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
// Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5).
|
||||
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
|
||||
shellPath,
|
||||
|
||||
@@ -9,6 +9,11 @@ import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-regi
|
||||
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
|
||||
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
|
||||
|
||||
const prepareManagedCodexHomeMock = vi.hoisted(() => vi.fn(() => null))
|
||||
vi.mock('../codex/managed-home-shell-preflight', () => ({
|
||||
prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeMock,
|
||||
resolveManagedCodexShellPreflightHome: () => null
|
||||
}))
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
@@ -244,6 +249,34 @@ describe('registerPtyHandlers', () => {
|
||||
// wrapper inherits, so the preflight must carry the CLI's verified absolute path.
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH)
|
||||
})
|
||||
// Why: cmd.exe has no deferred `codex` wrapper, so its preflight ran inline at
|
||||
// shell start as a CLI round-trip into this app — slow enough to push agent
|
||||
// launches past their startup budget. Same prep, done in-process instead.
|
||||
it('prepares the managed Codex home in-process instead of from the shell', async () => {
|
||||
prepareManagedCodexHomeMock.mockClear()
|
||||
const env = await withBundledCli(() =>
|
||||
spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
|
||||
)
|
||||
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH)
|
||||
expect(prepareManagedCodexHomeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hooksEnabled: true,
|
||||
env: expect.objectContaining({
|
||||
CODEX_HOME: TEST_CODEX_HOME,
|
||||
ORCA_CODEX_HOME: TEST_CODEX_HOME
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
it('does not prepare a managed Codex home when no preflight is installed', async () => {
|
||||
prepareManagedCodexHomeMock.mockClear()
|
||||
await withBundledCli(() => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME), {
|
||||
launcherExecutable: false
|
||||
})
|
||||
|
||||
expect(prepareManagedCodexHomeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
it('skips the Codex launch preflight when the bundled CLI is not executable', async () => {
|
||||
const env = await withBundledCli(
|
||||
() => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME),
|
||||
|
||||
+12
-1
@@ -61,7 +61,10 @@ import {
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
||||
import { ensureWslHookRelayForReattach } from '../agent-hooks/wsl-hook-relay-reattach'
|
||||
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
||||
import {
|
||||
isAgentStatusHooksEnabled,
|
||||
prepareManagedCodexHomeBeforeShellLaunch
|
||||
} from '../agent-hooks/managed-agent-hook-controls'
|
||||
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
|
||||
import {
|
||||
detectExplicitPiAgentKindFromCommand,
|
||||
@@ -1933,6 +1936,14 @@ export function buildPtyHostEnv(
|
||||
})
|
||||
if (preflightCommand) {
|
||||
baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand
|
||||
// Why: cmd.exe has no deferred `codex` wrapper, so its preflight ran inline
|
||||
// at shell start — a CLI round-trip back into this app that stalled agent
|
||||
// launches for seconds. Same idempotent prep, done in-process instead.
|
||||
prepareManagedCodexHomeBeforeShellLaunch({
|
||||
env: baseEnv,
|
||||
userDataPath: opts.userDataPath,
|
||||
hooksEnabled: true
|
||||
})
|
||||
} else {
|
||||
delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ describe('LocalPtyProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the Codex preflight once in the cmd.exe startup chain', async () => {
|
||||
it('keeps the Codex preflight out of the cmd.exe startup chain', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
provider.configure({
|
||||
@@ -601,14 +601,10 @@ describe('LocalPtyProvider', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"'
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */
|
||||
import { basename, delimiter, win32 as pathWin32 } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from './windows-shell-args'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
@@ -796,16 +793,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
delete finalEnv.ORCA_CODEX_HOME
|
||||
}
|
||||
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const codexLaunchPreflightCommand = finalEnv.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
if (
|
||||
codexLaunchPreflightCommand &&
|
||||
(shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(shellPath))
|
||||
) {
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
|
||||
finalEnv[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
if (codexLaunchPreflightCommand && isWindowsGitBashShellPath(shellPath)) {
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
cwd,
|
||||
|
||||
@@ -9,8 +9,6 @@ import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root'
|
||||
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT =
|
||||
'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
|
||||
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}`
|
||||
@@ -66,7 +64,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice')
|
||||
@@ -81,10 +79,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual([
|
||||
'/K',
|
||||
`chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen`
|
||||
])
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen'])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
})
|
||||
|
||||
@@ -111,7 +106,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
`codex ${'x'.repeat(7000)}`,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -308,7 +303,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('quotes a spaced preflight path through each shell environment', () => {
|
||||
it('keeps the Codex preflight out of cmd.exe args and in the Git Bash wrapper', () => {
|
||||
const cmd = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
@@ -326,10 +321,9 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(cmd.shellArgs[1]).toContain(
|
||||
'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%'
|
||||
)
|
||||
expect(cmd.shellArgs[1]).not.toContain('"')
|
||||
// Why: cmd.exe cannot defer the preflight behind a `codex` wrapper, so an
|
||||
// inline call would block every pane on a CLI round-trip into the app.
|
||||
expect(cmd.shellArgs[1]).not.toContain('prepare-codex')
|
||||
expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1])
|
||||
expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"')
|
||||
|
||||
@@ -18,8 +18,6 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191
|
||||
const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000
|
||||
const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000
|
||||
const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
|
||||
export const ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV = 'ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}%%ORCA_CODEX_LAUNCH_PREFLIGHT%%${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}% agent hooks prepare-codex > nul 2>&1`
|
||||
// Why: Git for Windows' bash inherits the ConPTY console's OEM code page
|
||||
// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents
|
||||
// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake
|
||||
@@ -183,11 +181,14 @@ export function resolveWindowsShellLaunchArgs(
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const nativeCwd = normalizeWindowsTerminalCwd(cwd)
|
||||
|
||||
// Why no Codex preflight here: cmd.exe has no deferred-wrapper equivalent, so
|
||||
// an inline `call orca agent hooks prepare-codex` would block the agent behind
|
||||
// a CLI round-trip into the running app. buildPtyHostEnv does that prep
|
||||
// in-process instead.
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
|
||||
const startupCommands = [
|
||||
CMD_UTF8_SETUP_COMMAND,
|
||||
...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []),
|
||||
...(shellArgStartupCommand ? [shellArgStartupCommand] : [])
|
||||
]
|
||||
return {
|
||||
|
||||
@@ -12,10 +12,7 @@ import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveGitBashPath } from '../git-bash'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from './windows-shell-args'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
|
||||
const describeWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
const tempDirs: string[] = []
|
||||
@@ -116,7 +113,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describeWindows('Windows Codex shell preflight runtime', () => {
|
||||
it('runs a spaced executable through cmd.exe and continues after failure', async () => {
|
||||
it('starts the cmd.exe startup command without blocking on the Codex preflight', async () => {
|
||||
const root = makeTempDir()
|
||||
const preflight = writeFailingPreflight(root)
|
||||
const preflightMarker = join(root, 'cmd-preflight-ran')
|
||||
@@ -130,6 +127,10 @@ describeWindows('Windows Codex shell preflight runtime', () => {
|
||||
preflight
|
||||
)
|
||||
|
||||
// Why: an inline preflight is a CLI round-trip into the running app; a slow
|
||||
// one delayed the agent past its launch budget, so it must not be in argv.
|
||||
expect(resolved.shellArgs.join(' ')).not.toContain('prepare-codex')
|
||||
|
||||
await runPty({
|
||||
shellPath: 'cmd.exe',
|
||||
shellArgs: resolved.shellArgs,
|
||||
@@ -137,12 +138,11 @@ describeWindows('Windows Codex shell preflight runtime', () => {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflight,
|
||||
[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV]: '"',
|
||||
ORCA_PREFLIGHT_MARKER: preflightMarker
|
||||
}
|
||||
})
|
||||
|
||||
expect(readFileSync(preflightMarker, 'utf8')).toBe('ran')
|
||||
expect(existsSync(preflightMarker)).toBe(false)
|
||||
expect(readFileSync(startupMarker, 'utf8').trim()).toBe('launched')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user