🐛 fix(desktop): spawn the detector-resolved Codex path, not the bare command

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 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-13 12:56:17 +08:00
parent c8c29b997f
commit 87b02119f1
2 changed files with 57 additions and 11 deletions
@@ -199,6 +199,16 @@ interface AgentSession {
type SessionErrorPayload = HeterogeneousAgentSessionError | string; 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 { interface CliTraceSession {
dir: string; dir: string;
writeQueue: Promise<void>; writeQueue: Promise<void>;
@@ -451,16 +461,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return relevantStderr || `Agent exited with code ${code}`; return relevantStderr || `Agent exited with code ${code}`;
} }
private async getSpawnPreflightError( private async runSpawnPreflight(session: AgentSession): Promise<SpawnPreflightResult> {
session: AgentSession,
): Promise<HeterogeneousAgentSessionError | undefined> {
const defaultCommand = const defaultCommand =
session.agentType === 'claude-code' session.agentType === 'claude-code'
? 'claude' ? 'claude'
: session.agentType === 'codex' : session.agentType === 'codex'
? 'codex' ? 'codex'
: undefined; : undefined;
if (!defaultCommand) return; if (!defaultCommand) return {};
const command = this.resolveSessionCommand(session); const command = this.resolveSessionCommand(session);
const status = const status =
@@ -472,9 +480,22 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
); );
const cliMissingError = this.buildCliMissingError(session); 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 { private get shouldTraceCliOutput(): boolean {
@@ -888,13 +909,13 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const session = this.sessions.get(params.sessionId); const session = this.sessions.get(params.sessionId);
if (!session) throw new Error(`Session not found: ${params.sessionId}`); if (!session) throw new Error(`Session not found: ${params.sessionId}`);
const preflightError = await this.getSpawnPreflightError(session); const preflight = await this.runSpawnPreflight(session);
if (preflightError) { if (preflight.error) {
this.broadcast('heteroAgentSessionError', { this.broadcast('heteroAgentSessionError', {
error: preflightError, error: preflight.error,
sessionId: session.sessionId, 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 // 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 useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args; const cliArgs = spawnPlan.args;
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs); const resolvedCliSpawnPlan = await resolveCliSpawnPlan(
preflight.resolvedCommand || session.command,
cliArgs,
);
logger.info( logger.info(
'Spawning agent:', 'Spawning agent:',
@@ -442,6 +442,28 @@ describe('HeterogeneousAgentCtr', () => {
expect(spawnCalls).toHaveLength(0); 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 () => { it('fails fast when a customized Claude command is unavailable instead of checking the default detector', async () => {
execFileMock.mockImplementation( execFileMock.mockImplementation(
( (