From 87b02119f1f90e117ee42f44117dc2b0ff084205 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sat, 13 Jun 2026 12:56:17 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(desktop):=20spawn=20the=20de?= =?UTF-8?q?tector-resolved=20Codex=20path,=20not=20the=20bare=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the bundle paths to the detector only made preflight report available; spawn still ran the bare `session.command` ('codex'). A user with only Codex.app (no `codex` on PATH) passed preflight but then hit ENOENT at spawn('codex'). Thread the absolute path the detector resolved out of preflight and feed it to resolveCliSpawnPlan/spawn. This also hardens the existing login-shell-PATH-only case, where spawn runs against a leaner env than detection did. Skipped on Windows, where resolveCliSpawnPlan does its own .cmd/.exe shim resolution from the bare command that we must not pre-empt. Co-Authored-By: Claude Opus 4.8 --- .../main/controllers/HeterogeneousAgentCtr.ts | 46 ++++++++++++++----- .../__tests__/HeterogeneousAgentCtr.test.ts | 22 +++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts index 31d611a999..0009a7ae84 100644 --- a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -199,6 +199,16 @@ interface AgentSession { type SessionErrorPayload = HeterogeneousAgentSessionError | string; +interface SpawnPreflightResult { + /** Set when the CLI is missing/unavailable — spawning must be aborted. */ + error?: HeterogeneousAgentSessionError; + /** + * Absolute path the detector resolved the CLI to, to spawn instead of the + * bare `session.command`. Undefined when the bare command should be used. + */ + resolvedCommand?: string; +} + interface CliTraceSession { dir: string; writeQueue: Promise; @@ -451,16 +461,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule { return relevantStderr || `Agent exited with code ${code}`; } - private async getSpawnPreflightError( - session: AgentSession, - ): Promise { + private async runSpawnPreflight(session: AgentSession): Promise { const defaultCommand = session.agentType === 'claude-code' ? 'claude' : session.agentType === 'codex' ? 'codex' : undefined; - if (!defaultCommand) return; + if (!defaultCommand) return {}; const command = this.resolveSessionCommand(session); const status = @@ -472,9 +480,22 @@ export default class HeterogeneousAgentCtr extends ControllerModule { ); const cliMissingError = this.buildCliMissingError(session); - if (!status || status.available || !cliMissingError) return; + if (!status || status.available || !cliMissingError) { + // The detector resolved the binary to an absolute path (e.g. the Codex.app + // bundle, or a login-shell-PATH-only install). spawn() runs against a leaner + // env than detection did, so a bare `codex` could still ENOENT even though + // preflight passed — feed the resolved absolute path through to spawn. + // + // Skip on Windows: there `resolveCliSpawnPlan` performs its own `.cmd`/`.exe` + // shim resolution from the bare command, which we must not pre-empt. + const resolvedCommand = + process.platform !== 'win32' && status?.available && status.path?.trim() + ? status.path + : undefined; + return { resolvedCommand }; + } - return cliMissingError; + return { error: cliMissingError }; } private get shouldTraceCliOutput(): boolean { @@ -888,13 +909,13 @@ export default class HeterogeneousAgentCtr extends ControllerModule { const session = this.sessions.get(params.sessionId); if (!session) throw new Error(`Session not found: ${params.sessionId}`); - const preflightError = await this.getSpawnPreflightError(session); - if (preflightError) { + const preflight = await this.runSpawnPreflight(session); + if (preflight.error) { this.broadcast('heteroAgentSessionError', { - error: preflightError, + error: preflight.error, sessionId: session.sessionId, }); - throw new Error(preflightError.message); + throw new Error(preflight.error.message); } // Stand up the AskUserQuestion MCP bridge for claude-code prompts BEFORE @@ -966,7 +987,10 @@ export default class HeterogeneousAgentCtr extends ControllerModule { } const useStdin = spawnPlan.stdinPayload !== undefined; const cliArgs = spawnPlan.args; - const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs); + const resolvedCliSpawnPlan = await resolveCliSpawnPlan( + preflight.resolvedCommand || session.command, + cliArgs, + ); logger.info( 'Spawning agent:', diff --git a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts index ec3d73fd96..4f93d233f9 100644 --- a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts @@ -442,6 +442,28 @@ describe('HeterogeneousAgentCtr', () => { expect(spawnCalls).toHaveLength(0); }); + it('spawns the detector-resolved absolute path (e.g. Codex.app bundle) instead of the bare command', async () => { + // A user with only the Codex desktop app has no `codex` on PATH; the + // detector resolves the bundled binary. Preflight passing isn't enough — + // spawn must target that absolute path or it ENOENTs on the bare command. + const bundlePath = '/Applications/Codex.app/Contents/Resources/codex'; + const detect = vi.fn().mockResolvedValue({ available: true, path: bundlePath }); + const { proc } = createFakeProc({ stdoutLines: [] }); + nextFakeProc = proc; + + const ctr = new HeterogeneousAgentCtr({ + appStoragePath, + storeManager: { get: vi.fn() }, + toolDetectorManager: { detect }, + } as any); + const { sessionId } = await ctr.startSession({ agentType: 'codex', command: 'codex' }); + await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }); + + expect(detect).toHaveBeenCalledWith('codex', true); + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].command).toBe(bundlePath); + }); + it('fails fast when a customized Claude command is unavailable instead of checking the default detector', async () => { execFileMock.mockImplementation( (