From 7f8ca49e3d215f0d5f29ee2fd9007ca2ef178b8d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:52:22 -0700 Subject: [PATCH] fix(codex): start short-lived Codex app-servers on Windows without cmd.exe (#23830) * fix(codex): start short-lived Codex app-servers on Windows without cmd.exe Orca's short-lived Codex app-server launches (history index passes, the state-DB recovery claimant, hook trust grants, and the rate-limit and model catalog probes) wrapped npm's codex.cmd in `cmd.exe /d /c` before handing it to spawnProcess. That hid the shim from spawnProcess's resolver, so every one of these launches went through cmd.exe instead of straight to node. Hand the bare CLI path to the spawn chokepoint like the chat session already does. The rate-limit probe and the recovery claimant also move off direct node:child_process imports, shrinking that allowlist by two. * chore(codex): drop stale cmd.exe premises from the probe kill and CLI pairing comments Short-lived Codex app-servers now reach spawnProcess as the bare shim, so the direct child is node for a resolved npm shim and cmd.exe only as the fallback. * test(child-process): delist the rate-limit probe from the hidden-console ratchet The probe now spawns through spawnProcess, which always hides the console. --- .../codex-app-server-process-tree-kill.ts | 4 +- src/main/codex/codex-app-server-session.ts | 2 +- src/main/codex/codex-model-catalog-probe.ts | 8 +- src/main/codex/codex-session-index-heal.ts | 12 +-- ...codex-short-lived-app-server-spawn.test.ts | 74 +++++++++++++++++++ .../codex-state-db-backfill-recovery.test.ts | 9 +-- .../codex/codex-state-db-backfill-recovery.ts | 20 ++--- src/main/codex/codex-trust-grant-host.ts | 6 +- .../codex-fetcher-process-contract.test.ts | 28 ++++--- ...codex-fetcher-rpc-exit-diagnostics.test.ts | 28 ++++--- .../codex-fetcher-runtime-pairing.test.ts | 20 +++-- src/main/rate-limits/codex-fetcher.test.ts | 19 +++-- src/main/rate-limits/codex-fetcher.ts | 18 ++--- .../rate-limits/codex-probe-termination.ts | 4 +- .../child-process-import-allowlist.txt | 2 - .../windows-console-visibility-allowlist.txt | 1 - .../windows-console-visibility.test.ts | 2 +- 17 files changed, 167 insertions(+), 90 deletions(-) create mode 100644 src/main/codex/codex-short-lived-app-server-spawn.test.ts diff --git a/src/main/codex/codex-app-server-process-tree-kill.ts b/src/main/codex/codex-app-server-process-tree-kill.ts index 315246aaa9f..1d8d0f10bda 100644 --- a/src/main/codex/codex-app-server-process-tree-kill.ts +++ b/src/main/codex/codex-app-server-process-tree-kill.ts @@ -32,8 +32,8 @@ export function killCodexAppServerProcessTree( return } try { - // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper - // leaves the app-server child alive after a timeout or failed shutdown. + // Why: npm-installed Codex runs behind a launcher (node, or cmd.exe for an unresolved shim); + // killing only that wrapper leaves the app-server child alive after a timeout. const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 067fdd6d6b7..389cd20516c 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -25,7 +25,7 @@ export type CodexAppServerInvocation = { * * Required, and `null` only for a guest-side launcher (wsl.exe) where the host * path means nothing. Optional would let a native builder omit it and silently - * fall back to pairing against a cmd.exe wrapper with no type error. + * skip the pairing with no type error. */ cliPath: string | null /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 66914f51991..2cf354259d7 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -1,4 +1,3 @@ -import { getSpawnArgsForWindows } from '../win32-utils' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { runCodexAppServerSession } from './codex-app-server-session' import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' @@ -44,14 +43,11 @@ export function createCodexModelCatalogProbe( ): AgentModelCatalogProbe { return async (accountHomePath: string): Promise => { const { command, environment } = await resolveCodexStructuredInvocation(deps) - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ - ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS - ]) const run = deps.runSession ?? runCodexAppServerSession const listing = await run( { - command: spawnCmd, - args: spawnArgs, + command, + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 364263f2f47..5ce55c8e082 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -2,7 +2,6 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' -import { getSpawnArgsForWindows } from '../win32-utils' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' import { @@ -291,14 +290,11 @@ export function buildNativeHealInvocation( timeoutMs: number ): CodexAppServerInvocation { const command = resolveCodexCommand() - // Why: each session is torn down after one batch, so plugin startup could - // leave marketplace clones running; indexing needs neither plugins nor tools. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ - ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS - ]) return { - command: spawnCmd, - args: spawnArgs, + command, + // Why: each session is torn down after one batch, so plugin startup could + // leave marketplace clones running; indexing needs neither plugins nor tools. + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, // Why: pin the home explicitly — nested Orca launches can inherit a managed // CODEX_HOME from the daemon environment, which would index the wrong sqlite DB. diff --git a/src/main/codex/codex-short-lived-app-server-spawn.test.ts b/src/main/codex/codex-short-lived-app-server-spawn.test.ts new file mode 100644 index 00000000000..cdb67ff4640 --- /dev/null +++ b/src/main/codex/codex-short-lived-app-server-spawn.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' +import type { CodexAppServerInvocation } from './codex-app-server-session' + +const NPM_CODEX_SHIM = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd' + +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => NPM_CODEX_SHIM })) + +import { buildNativeHealInvocation } from './codex-session-index-heal' +import { resolveNativeCodexTrustGrantHost } from './codex-trust-grant-host' +import { createCodexModelCatalogProbe } from './codex-model-catalog-probe' + +// Why: spawnProcess resolves npm's codex.cmd past cmd.exe, but only when it is handed the shim. +// A builder that pre-wraps it in `cmd.exe /d /c` puts cmd.exe back into every short-lived +// Codex launch — hundreds of them while a large history indexes. +describe('short-lived Codex app-server invocations on Windows', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('hands the index-heal session the shim itself', () => { + const invocation = buildNativeHealInvocation('C:\\homes\\a', 1_000) + expect(invocation.command).toBe(NPM_CODEX_SHIM) + expect(invocation.cliPath).toBe(NPM_CODEX_SHIM) + expect(invocation.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) + }) + + it('hands the hook trust grant session the shim itself', () => { + const request = resolveNativeCodexTrustGrantHost().buildRequest({ + runtimeHomePath: 'C:\\homes\\a', + managedCommand: 'orca-hook', + expectedTrustKeys: [] + }) + expect(request.invocation.command).toBe(NPM_CODEX_SHIM) + expect(request.invocation.args).toEqual(['app-server']) + }) + + it('hands the model catalog probe session the shim itself', async () => { + const invocations: CodexAppServerInvocation[] = [] + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: 'C:\\bin' }), + resolveCommand: () => NPM_CODEX_SHIM, + runSession: async (invocation, body) => { + invocations.push(invocation) + return body({ + request: async () => ({ + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + isDefault: true + } + ], + nextCursor: null + }), + notify: () => {} + }) + } + }) + await probe('C:\\homes\\a') + expect(invocations[0]?.command).toBe(NPM_CODEX_SHIM) + expect(invocations[0]?.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) + }) +}) diff --git a/src/main/codex/codex-state-db-backfill-recovery.test.ts b/src/main/codex/codex-state-db-backfill-recovery.test.ts index 1986ccbdae9..cc20eef34df 100644 --- a/src/main/codex/codex-state-db-backfill-recovery.test.ts +++ b/src/main/codex/codex-state-db-backfill-recovery.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import * as ownerIdentity from '../agent-hooks/managed-hook-owner-identity' import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' -import { getCmdExePath } from '../win32-utils' import { _internals, resolveCodexBackfillSupervisorLockRoot, @@ -365,7 +364,7 @@ describe('Codex state DB backfill recovery', () => { expect(terminate).toHaveBeenCalledWith(child) }) - it('uses batch-safe read-only arguments for native Windows recovery', async () => { + it('hands the Codex shim itself to the spawn chokepoint for native Windows recovery', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const child = createFakeChild() const spawnProcess = vi.fn(() => child) @@ -393,11 +392,9 @@ describe('Codex state DB backfill recovery', () => { string[], { cwd?: string; env?: NodeJS.ProcessEnv } ] - expect(spawnFile).toBe(getCmdExePath()) + // Why: spawnProcess resolves a recognised npm shim past cmd.exe; pre-wrapping hides the shim. + expect(spawnFile).toBe(codexCommand) expect(spawnArgs).toEqual([ - '/d', - '/c', - codexCommand, '-c', 'approval_policy=never', '-s', diff --git a/src/main/codex/codex-state-db-backfill-recovery.ts b/src/main/codex/codex-state-db-backfill-recovery.ts index 16d56d5907f..b4acef3b337 100644 --- a/src/main/codex/codex-state-db-backfill-recovery.ts +++ b/src/main/codex/codex-state-db-backfill-recovery.ts @@ -1,4 +1,3 @@ -import { spawn, type ChildProcess } from 'node:child_process' import { createHash } from 'node:crypto' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' @@ -11,7 +10,11 @@ import { resolveCodexCommand } from '../codex-cli/command' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { terminateCodexProbeChild } from '../rate-limits/codex-probe-termination' -import { getSpawnArgsForWindows } from '../win32-utils' +import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import { + spawnCodexAppServerProcess, + type CodexAppServerSpawn +} from './codex-app-server-process-tree-kill' import { getOrcaUserDataPath } from './codex-home-paths' import { BACKFILL_PENDING_MIN_SESSION_FILES, @@ -34,17 +37,17 @@ export type CodexStateDbBackfillRecoverySummary = { } type RecoveryDependencies = { - spawnProcess: typeof spawn + spawnProcess: CodexAppServerSpawn resolveCommand: () => string readStatus: (codexHomePath: string) => CodexStateDbBackfillStatus countSessions: (sessionsRoot: string, limit: number) => number now: () => number sleep: (ms: number, signal: AbortSignal) => Promise - terminate: (child: ChildProcess) => Promise + terminate: (child: ChildProcessHandle) => Promise } const defaultDependencies: RecoveryDependencies = { - spawnProcess: spawn, + spawnProcess: spawnCodexAppServerProcess, resolveCommand: resolveCodexCommand, readStatus: readCodexStateDbBackfillStatus, countSessions: countCodexSessionFilesUpTo, @@ -86,7 +89,7 @@ function initialRecoveryDecision( function spawnRecoveryProcess( codexHomePath: string, dependencies: RecoveryDependencies -): ChildProcess { +): ChildProcessHandle { const wslHome = process.platform === 'win32' ? parseWslUncPath(codexHomePath) : null if (wslHome) { return dependencies.spawnProcess( @@ -104,10 +107,7 @@ function spawnRecoveryProcess( ) } const command = dependencies.resolveCommand() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ - ...CODEX_READ_ONLY_APP_SERVER_ARGS - ]) - return dependencies.spawnProcess(spawnCmd, spawnArgs, { + return dependencies.spawnProcess(command, [...CODEX_READ_ONLY_APP_SERVER_ARGS], { cwd: codexHomePath, stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index 9228fad43ce..d7fb8e45975 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -1,6 +1,5 @@ import { runProcess } from '../../shared/child-process/run-process' import { resolveCodexCommand } from '../codex-cli/command' -import { getSpawnArgsForWindows } from '../win32-utils' import { buildWslCodexAppServerArgs, buildWslCodexIdentityProbe, @@ -73,12 +72,11 @@ export function resolveNativeCodexTrustGrantHost(): ResolvedCodexTrustGrantHost return { binaryStamp: command === 'codex' ? null : buildNativeCodexBinaryStamp(command), buildRequest: (input) => { - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) const useDefaultCodexHome = input.useDefaultCodexHome === true return { invocation: { - command: spawnCmd, - args: spawnArgs, + command, + args: ['app-server'], cliPath: command, ...(useDefaultCodexHome ? { envToDelete: ['CODEX_HOME'] } diff --git a/src/main/rate-limits/codex-fetcher-process-contract.test.ts b/src/main/rate-limits/codex-fetcher-process-contract.test.ts index 8d067a686ad..b698c706c6a 100644 --- a/src/main/rate-limits/codex-fetcher-process-contract.test.ts +++ b/src/main/rate-limits/codex-fetcher-process-contract.test.ts @@ -2,18 +2,27 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as Win32Utils from '../win32-utils' +import type * as RunProcess from '../../shared/child-process/run-process' -const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ - getSpawnArgsForWindowsMock: vi.fn(), +const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ({ + stubScript: { path: '' }, ptySpawnMock: vi.fn(), resolveCodexCommandMock: vi.fn() })) -vi.mock('../win32-utils', async (importOriginal) => ({ - ...(await importOriginal()), - getSpawnArgsForWindows: getSpawnArgsForWindowsMock -})) +// Runs the node stub in place of the resolved CLI, through the real chokepoint. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawnProcess: (spec: Parameters[0]) => + actual.spawnProcess({ + ...spec, + program: process.execPath, + args: [stubScript.path, ...(spec.args ?? [])] + }) + } +}) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock @@ -105,10 +114,7 @@ describe('Codex rate-limit process contract', () => { previousExpectedHome = process.env.ORCA_EXPECTED_CODEX_HOME process.env.ORCA_EXPECTED_CODEX_HOME = join(tempRoot, 'managed-codex-home') resolveCodexCommandMock.mockReturnValue('codex') - getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ - spawnCmd: process.execPath, - spawnArgs: [stubPath, ...args] - })) + stubScript.path = stubPath }) afterEach(() => { diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts index ee874ef5942..441be8d836d 100644 --- a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -2,19 +2,28 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as Win32Utils from '../win32-utils' +import type * as RunProcess from '../../shared/child-process/run-process' import { isCodexAuthError } from '../../shared/codex-auth-errors' -const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ - getSpawnArgsForWindowsMock: vi.fn(), +const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ({ + stubScript: { path: '' }, ptySpawnMock: vi.fn(), resolveCodexCommandMock: vi.fn() })) -vi.mock('../win32-utils', async (importOriginal) => ({ - ...(await importOriginal()), - getSpawnArgsForWindows: getSpawnArgsForWindowsMock -})) +// Runs the node stub in place of the resolved CLI, through the real chokepoint. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawnProcess: (spec: Parameters[0]) => + actual.spawnProcess({ + ...spec, + program: process.execPath, + args: [stubScript.path, ...(spec.args ?? [])] + }) + } +}) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock @@ -62,10 +71,7 @@ describe('Codex RPC exit diagnostics', () => { stubPath = join(tempRoot, 'codex-exit-stub.cjs') writeFileSync(stubPath, STUB_CODEX_SOURCE) resolveCodexCommandMock.mockReturnValue('codex') - getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ - spawnCmd: process.execPath, - spawnArgs: [stubPath, ...args] - })) + stubScript.path = stubPath }) afterEach(() => { diff --git a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts index 0353bd4c31d..d825bb65fd8 100644 --- a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts +++ b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RunProcess from '../../shared/child-process/run-process' const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ childSpawnMock: vi.fn(), @@ -12,6 +13,12 @@ const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = })) vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +// The chokepoint is the seam: assertions see what the fetcher asked for, before shim resolution. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + spawnProcess: (spec: { program: string; args?: readonly string[] }) => + childSpawnMock(spec.program, spec.args ?? [], spec) +})) vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) @@ -79,18 +86,17 @@ describe('codex rate-limit spawn runtime pairing', () => { await vi.advanceTimersByTimeAsync(0) const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv - // Guards the argument choice: pairing spawnCmd (cmd.exe on win32) rather than - // the resolved CLI silently reverts the ABI fix (stablyai/orca#10932). + // Guards the argument choice: pairing anything but the resolved CLI silently + // reverts the ABI fix (stablyai/orca#10932). expect(spawnEnv.PATH?.split(delimiter)[0]).toBe(bin) rpcChild.emit('close') await resultPromise }) - it('pairs the resolved CLI on win32, where the spawn command is cmd.exe', async () => { - // Why win32 specifically: on posix getSpawnArgsForWindows returns the CLI - // itself, so pairing the spawn command instead of the resolved CLI is - // indistinguishable. Only here does the wrong argument become cmd.exe. + it('hands an npm .cmd shim to the spawn chokepoint unwrapped, paired with its node', async () => { + // Why win32 specifically: only here could a pre-wrap turn the program into + // cmd.exe, which hides the shim from spawnProcess's resolver. const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) try { @@ -103,7 +109,7 @@ describe('codex rate-limit spawn runtime pairing', () => { await vi.advanceTimersByTimeAsync(0) const spawnCommand = childSpawnMock.mock.calls[0]?.[0] as string - expect(spawnCommand.toLowerCase()).toContain('cmd.exe') + expect(spawnCommand).toBe(cli) const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv expect((spawnEnv.Path ?? spawnEnv.PATH)?.split(';')[0]).toBe(bin) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index be6edd3b3fb..7d5b7423f7c 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RunProcess from '../../shared/child-process/run-process' const { childSpawnMock, @@ -22,6 +23,13 @@ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +// The chokepoint is the seam: assertions see what the fetcher asked for, before shim resolution. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + spawnProcess: (spec: { program: string; args?: readonly string[] }) => + childSpawnMock(spec.program, spec.args ?? [], spec) +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) @@ -51,7 +59,6 @@ vi.mock('./codex-auth-presence', () => ({ import { fetchCodexRateLimits } from './codex-fetcher' import { probeCodexAuthPresence } from './codex-auth-presence' import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' -import { getCmdExePath } from '../win32-utils' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' function makeDisposable() { @@ -787,13 +794,9 @@ describe('fetchCodexRateLimits', () => { await resultPromise const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] - expect(spawnFile).toBe(getCmdExePath()) - expect(spawnArgs).toEqual([ - '/d', - '/c', - codexCommand, - ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS - ]) + // Pre-wrapping in cmd.exe would hide the npm shim from spawnProcess's resolver. + expect(spawnFile).toBe(codexCommand) + expect(spawnArgs).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) expect(spawnOptions).toEqual( expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 1217500fb2c..804cd22c660 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -1,5 +1,4 @@ import type { CodexRateLimitResetOutcome, ProviderRateLimits } from '../../shared/rate-limit-types' -import { spawn } from 'node:child_process' import { isCodexAuthError } from '../../shared/codex-auth-errors' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -18,7 +17,8 @@ import { import { isCodexStateDbBackfillPending } from '../codex/codex-state-db' import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' +import { spawnProcess } from '../../shared/child-process/run-process' +import { getCmdExePath } from '../win32-utils' import { probeCodexAuthPresence } from './codex-auth-presence' import { fetchCodexRateLimitsViaBackend, @@ -109,19 +109,17 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise