mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<AgentModelCatalogSuccess> => {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void>
|
||||
terminate: (child: ChildProcess) => Promise<void>
|
||||
terminate: (child: ChildProcessHandle) => Promise<void>
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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'] }
|
||||
|
||||
@@ -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<typeof Win32Utils>()),
|
||||
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<typeof RunProcess>()
|
||||
return {
|
||||
...actual,
|
||||
spawnProcess: (spec: Parameters<typeof actual.spawnProcess>[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(() => {
|
||||
|
||||
@@ -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<typeof Win32Utils>()),
|
||||
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<typeof RunProcess>()
|
||||
return {
|
||||
...actual,
|
||||
spawnProcess: (spec: Parameters<typeof actual.spawnProcess>[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(() => {
|
||||
|
||||
@@ -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<typeof RunProcess>()),
|
||||
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)
|
||||
|
||||
|
||||
@@ -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<typeof RunProcess>()),
|
||||
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' })
|
||||
|
||||
@@ -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<Provid
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs, true)
|
||||
: null
|
||||
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
|
||||
const { spawnCmd, spawnArgs } = wslCodex
|
||||
? { spawnCmd: wslCodex.command, spawnArgs: wslCodex.args }
|
||||
: getSpawnArgsForWindows(codexCommand, codexArgs)
|
||||
const spawnOptions = {
|
||||
stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'],
|
||||
// Why the bare CLI: spawnProcess resolves an npm `codex.cmd` shim past cmd.exe itself.
|
||||
const child = spawnProcess({
|
||||
program: wslCodex ? wslCodex.command : codexCommand,
|
||||
args: wslCodex ? wslCodex.args : codexArgs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
windowsHide: true,
|
||||
env: withCliRuntimeOnPath(codexCommand, {
|
||||
...(wslCodex ? processEnvWithoutCodexHome() : process.env),
|
||||
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
|
||||
})
|
||||
}
|
||||
const child = spawn(spawnCmd, spawnArgs, spawnOptions)
|
||||
})
|
||||
return readCodexRateLimitsViaRpc({
|
||||
child: child as CodexRpcRateLimitChild,
|
||||
codexCommand,
|
||||
|
||||
@@ -88,8 +88,8 @@ export async function terminateCodexProbeChild(
|
||||
}
|
||||
if (platform === 'win32' && child.pid) {
|
||||
try {
|
||||
// npm-installed Codex runs beneath cmd.exe; killing only that wrapper can
|
||||
// leave app-server alive after the credential-home lock is released.
|
||||
// npm-installed Codex runs beneath a launcher (node, or cmd.exe for an unresolved
|
||||
// shim); killing only that wrapper can leave app-server alive after the lock is released.
|
||||
await (options?.killWindowsProcessTree ?? terminateWindowsProcessTree)(child.pid, {
|
||||
site: 'codex-rate-limit-probe'
|
||||
})
|
||||
|
||||
@@ -58,7 +58,6 @@ src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.
|
||||
src/main/codex-accounts/service.ts
|
||||
src/main/codex/codex-app-server-posix-supervisor.ts
|
||||
src/main/codex/codex-app-server-session.ts
|
||||
src/main/codex/codex-state-db-backfill-recovery.ts
|
||||
src/main/codex/codex-wsl-hook-install-plan.ts
|
||||
src/main/computer/desktop-script-provider-bridge.ts
|
||||
src/main/computer/macos-computer-use-permission-status.ts
|
||||
@@ -131,7 +130,6 @@ src/main/providers/windows-console-attached-processes.ts
|
||||
src/main/pty-descendant-termination.ts
|
||||
src/main/pty/posix-pty-foreground-group.ts
|
||||
src/main/pty/windows-environment-path.ts
|
||||
src/main/rate-limits/codex-fetcher.ts
|
||||
src/main/rate-limits/gemini-cli-oauth-extractor.ts
|
||||
src/main/runtime/tls-certificate.ts
|
||||
src/main/runtime/windows-default-route-interfaces.ts
|
||||
|
||||
@@ -38,7 +38,6 @@ main/providers/process-cwd.ts
|
||||
main/pty-descendant-termination.ts
|
||||
main/pty/posix-pty-foreground-group.ts
|
||||
main/pty/windows-environment-path.ts
|
||||
main/rate-limits/codex-fetcher.ts
|
||||
main/runtime/tls-certificate.ts
|
||||
main/ssh/ssh-connection.ts
|
||||
main/startup/ensure-virtual-display.ts
|
||||
|
||||
@@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist(
|
||||
* the allowlist does not bound this: a swap (one file fixed and delisted, one
|
||||
* new file added with its entry) satisfies both membership assertions.
|
||||
*/
|
||||
const UNHIDDEN_SPAWNER_PIN = 61
|
||||
const UNHIDDEN_SPAWNER_PIN = 60
|
||||
|
||||
const CHILD_PROCESS_IMPORT =
|
||||
/from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/
|
||||
|
||||
Reference in New Issue
Block a user