🐛 fix(desktop): detect bundled Codex CLI from Codex.app on macOS (#15759)

* 🐛 fix(desktop): detect bundled Codex CLI from Codex.app on macOS

OpenAI's Codex desktop app bundles the real codex CLI inside Codex.app
(Contents/Resources/codex) but never symlinks it onto PATH. A user with
only the desktop app installed failed PATH-based detection, so codex was
never spawned and the chat silently produced no reply.

Add a well-known install-location fallback inside detectHeterogeneousCliCommand
(tried after the PATH lookup, so a user's own install still wins), covering
both /Applications and ~/Applications. The fallback runs at detection time,
not module load, so it touches no node:os named exports on import. Feed the
detector-resolved absolute path through to spawn so a bare `codex` doesn't
ENOENT under spawn's leaner env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(desktop): carry login-shell PATH into CLI spawn env

When the detector resolved a bare command via the login-shell PATH, only
the absolute shim path was kept; the PATH used for resolution was dropped.
spawn() then built its env from the leaner Finder-inherited PATH, so an
absolute shim with `#!/usr/bin/env node` still failed with
`env: node: No such file or directory` even though preflight succeeded
(npm/Homebrew/mise installs launched from Finder on macOS).

Surface the resolved PATH through ToolStatus.resolvedPathEnv, stash it on
the session, and merge it into spawnEnv (session.env still wins). Only set
when resolution fell back to the login-shell PATH, so the common on-PATH
case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-13 16:32:27 +08:00
committed by GitHub
parent c9325794e5
commit 531900cf70
5 changed files with 252 additions and 11 deletions
@@ -188,6 +188,21 @@ interface AgentSession {
modelVerificationLastAttemptAt?: number;
modelVerificationLastAttemptSessionId?: string;
process?: ChildProcess;
/**
* Absolute CLI path resolved by spawn preflight detection. Used for spawn()
* when the configured command is bare: detection can find the CLI through
* the login-shell PATH or a well-known install location (e.g. the Codex.app
* bundled CLI) that plain spawn() with the inherited env can't resolve.
*/
resolvedCommandPath?: string;
/**
* PATH the preflight detector used to resolve `resolvedCommandPath`, set only
* when it fell back to the login-shell PATH. Merged into the child PATH at
* spawn so a `#!/usr/bin/env node` shim still finds its interpreter — the
* shim resolving in preflight doesn't guarantee `node` is on the leaner
* inherited PATH (Finder-launched Electron).
*/
resolvedCommandSearchPath?: string;
resumeSessionId?: string;
sessionId: string;
verifiedModel?: string;
@@ -470,11 +485,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
session.agentType === 'claude-code' ? 'claude-code' : 'codex',
command,
);
const cliMissingError = this.buildCliMissingError(session);
if (!status || status.available || !cliMissingError) return;
if (!status || status.available) {
// Spawn through the detector-resolved absolute path when the configured
// command is bare — detection may have located the CLI somewhere plain
// spawn() can't (login-shell PATH, Codex.app bundled CLI, …).
const useResolvedPath = Boolean(status?.path) && !command.includes(path.sep);
session.resolvedCommandPath = useResolvedPath ? status!.path : undefined;
// Carry the login-shell PATH the detector resolved through, so a
// `#!/usr/bin/env node` shim spawned by absolute path still finds `node`.
session.resolvedCommandSearchPath = useResolvedPath ? status!.resolvedPathEnv : undefined;
return;
}
return cliMissingError;
return this.buildCliMissingError(session);
}
private get shouldTraceCliOutput(): boolean {
@@ -935,7 +959,12 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
spawnEnv = { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env };
const inheritedEnv = buildInheritedSpawnEnv();
// When preflight resolved the CLI via the login-shell PATH, spawn with
// that PATH (a superset of the inherited one) so a `#!/usr/bin/env node`
// shim finds its interpreter. `session.env` still wins if it sets PATH.
if (session.resolvedCommandSearchPath) inheritedEnv.PATH = session.resolvedCommandSearchPath;
spawnEnv = { ...inheritedEnv, ...proxyEnv, ...session.env };
if (session.agentType === 'codex') {
const initialModel = await resolveCodexInitialModel({
@@ -973,7 +1002,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(
session.resolvedCommandPath ?? session.command,
cliArgs,
);
logger.info(
'Spawning agent:',
@@ -480,6 +480,87 @@ describe('HeterogeneousAgentCtr', () => {
expect(spawnCalls).toHaveLength(0);
});
it('spawns through the detector-resolved absolute path when the bare command is off PATH', async () => {
// Codex desktop app case: `codex` is not on PATH, but the preflight
// detector finds the CLI bundled inside Codex.app. Spawning the bare
// command would ENOENT — spawn must use the resolved absolute path.
const resolvedPath = '/Applications/Codex.app/Contents/Resources/codex';
const detect = vi.fn().mockResolvedValue({ available: true, path: resolvedPath });
const { proc } = createFakeProc();
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(spawnCalls[0].command).toBe(resolvedPath);
});
it('carries the detector login-shell PATH into the spawn env for `env node` shims', async () => {
// `codex` resolved via the login-shell PATH (mise/nvm). Spawning the
// absolute shim under the leaner inherited PATH would fail at its
// `#!/usr/bin/env node` shebang — the resolved PATH must reach the child.
const resolvedPath = '/Users/h/.local/share/mise/shims/codex';
const searchPath = '/Users/h/.local/share/mise/shims:/usr/bin:/bin';
const detect = vi
.fn()
.mockResolvedValue({ available: true, path: resolvedPath, resolvedPathEnv: searchPath });
const { proc } = createFakeProc();
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(spawnCalls[0].command).toBe(resolvedPath);
expect(spawnCalls[0].options.env.PATH).toBe(searchPath);
});
it('keeps an explicit path-like command for spawn instead of the detector result', async () => {
// detectHeterogeneousCliCommand validates the custom path via --version.
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null, result: { stderr: string; stdout: string }) => void,
) => {
const resolvedCallback =
typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
(resolvedCallback as any)?.(null, { stderr: '', stdout: 'codex-cli 0.99.0' });
},
);
const detect = vi.fn();
const { proc } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: '/custom/bin/codex',
});
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
expect(detect).not.toHaveBeenCalled();
expect(spawnCalls[0].command).toBe('/custom/bin/codex');
});
it('passes prompt via stdin to codex exec instead of argv', async () => {
const prompt = '--run a shell-like prompt safely';
const { cliArgs, command, writes } = await runSendPrompt(prompt);
@@ -15,6 +15,15 @@ export interface ToolStatus {
error?: string;
lastChecked?: Date;
path?: string;
/**
* PATH value used to resolve/validate the command, surfaced only when it
* differs from the detector process's `process.env.PATH` (e.g. resolution
* fell back to the login-shell PATH). A caller that spawns the resolved
* `path` must carry this into the child's PATH, or a `#!/usr/bin/env node`
* shim that resolved here still fails with `env: node: No such file or
* directory` under the leaner inherited env.
*/
resolvedPathEnv?: string;
version?: string;
}
@@ -1,5 +1,6 @@
import * as childProcess from 'node:child_process';
import * as os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -180,6 +181,76 @@ describe('cliAgentDetectors', () => {
expect(status.path).toBe('/usr/local/bin/claude');
expect(execMock).not.toHaveBeenCalled();
expect(execFileMock).toHaveBeenCalledTimes(2);
// Resolved on the inherited PATH — nothing extra to carry into spawn.
expect(status.resolvedPathEnv).toBeUndefined();
});
it('falls back to the Codex.app bundled CLI when `codex` is not on any PATH', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
// Deterministic env: no SHELL → no login-shell lookup, merged PATH
// equals process.env.PATH → no second `which` attempt.
process.env.PATH = '/usr/bin:/bin';
delete process.env.SHELL;
try {
callExecFileError(new Error('not found')); // which codex
callExecFile('codex-cli 0.138.0'); // bundled CLI --version
const { codexDetector } = await import('../cliAgentDetectors');
const status = await codexDetector.detect();
expect(status.available).toBe(true);
expect(status.path).toBe('/Applications/Codex.app/Contents/Resources/codex');
expect(status.version).toBe('codex-cli 0.138.0');
expect(execFileMock).toHaveBeenCalledTimes(2);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
expect(execFileMock.mock.calls[1]![0]).toBe(
'/Applications/Codex.app/Contents/Resources/codex',
);
} finally {
process.env.PATH = originalPath;
if (originalShell === undefined) delete process.env.SHELL;
else process.env.SHELL = originalShell;
}
});
it('stays unavailable when neither PATH nor the well-known locations have codex', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
process.env.PATH = '/usr/bin:/bin';
delete process.env.SHELL;
try {
callExecFileError(new Error('not found')); // which codex
callExecFileError(new Error('ENOENT')); // /Applications candidate
callExecFileError(new Error('ENOENT')); // ~/Applications candidate
const { codexDetector } = await import('../cliAgentDetectors');
const status = await codexDetector.detect();
expect(status.available).toBe(false);
expect(execFileMock).toHaveBeenCalledTimes(3);
expect(execFileMock.mock.calls[2]![0]).toBe(
path.join(os.homedir(), 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
);
} finally {
process.env.PATH = originalPath;
if (originalShell === undefined) delete process.env.SHELL;
else process.env.SHELL = originalShell;
}
});
it('does not probe well-known locations for an explicit path-like command', async () => {
callExecFileError(new Error('ENOENT')); // /custom/bin/codex --version
const { detectHeterogeneousCliCommand } = await import('../cliAgentDetectors');
const status = await detectHeterogeneousCliCommand('codex', '/custom/bin/codex');
expect(status.available).toBe(false);
// Only the explicit path's --version attempt — no fallback probing.
expect(execFileMock).toHaveBeenCalledTimes(1);
});
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
@@ -200,6 +271,12 @@ describe('cliAgentDetectors', () => {
expect(status.available).toBe(true);
expect(status.path).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(status.version).toBe('gemini 0.2.0');
// The login-shell PATH that resolved the shim must be surfaced so the
// spawn site can carry it into the child env (mise/nvm `node` lives
// there, not on the leaner inherited PATH).
expect(status.resolvedPathEnv).toBe(
'/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
);
expect(execFileMock).toHaveBeenCalledTimes(4);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
@@ -1,5 +1,5 @@
import { exec, execFile } from 'node:child_process';
import { platform } from 'node:os';
import { homedir, platform } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
@@ -190,6 +190,11 @@ const detectValidatedCommand = async (
return {
available: true,
path: resolvedPath,
// `env` is set only when resolution fell back to the login-shell PATH.
// Surface that PATH so the spawn site can carry it into the child env —
// otherwise a `#!/usr/bin/env node` shim resolved here can't find `node`
// under the leaner inherited PATH (Finder-launched Electron).
resolvedPathEnv: env?.PATH,
version: output.split(/\r?\n/)[0],
};
} catch {
@@ -209,6 +214,27 @@ const HETEROGENEOUS_CLI_AGENT_OPTIONS = {
Pick<ValidatedDetectorOptions, 'validateKeywords'>
>;
// Well-known absolute install locations probed when a bare command isn't on
// PATH. The Codex desktop app bundles a fully functional CLI inside Codex.app
// (sharing ~/.codex auth/config) but never symlinks it into PATH, so
// `which codex` misses an otherwise working install.
const getWellKnownCommandPaths = (agentType: HeterogeneousCliAgentType): string[] => {
if (platform() !== 'darwin') return [];
switch (agentType) {
case 'codex': {
const bundledCli = path.join('Codex.app', 'Contents', 'Resources', 'codex');
return [
path.join('/Applications', bundledCli),
path.join(homedir(), 'Applications', bundledCli),
];
}
default: {
return [];
}
}
};
export const detectHeterogeneousCliCommand = async (
agentType: HeterogeneousCliAgentType,
command: string,
@@ -216,7 +242,20 @@ export const detectHeterogeneousCliCommand = async (
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
if (!validator) return { available: false };
return detectValidatedCommand(command, validator);
const status = await detectValidatedCommand(command, validator);
if (status.available) return status;
// A bare command missing from PATH may still live at a well-known install
// location (e.g. the Codex desktop app's bundled CLI). Don't second-guess
// an explicit user-configured path.
if (!command.trim().includes(path.sep)) {
for (const candidate of getWellKnownCommandPaths(agentType)) {
const fallbackStatus = await detectValidatedCommand(candidate, validator);
if (fallbackStatus.available) return fallbackStatus;
}
}
return status;
};
/**
@@ -261,14 +300,17 @@ export const claudeCodeDetector: IToolDetector = createValidatedDetector({
/**
* OpenAI Codex CLI
* @see https://github.com/openai/codex
*
* Goes through `detectHeterogeneousCliCommand` so the Codex.app bundled-CLI
* fallback applies here too, keeping the manager path and the custom-command
* path in sync.
*/
export const codexDetector: IToolDetector = createValidatedDetector({
candidates: ['codex'],
export const codexDetector: IToolDetector = {
description: 'Codex - OpenAI agentic coding CLI',
detect: () => detectHeterogeneousCliCommand('codex', 'codex'),
name: 'codex',
priority: 2,
validateKeywords: ['codex'],
});
};
/**
* Google Gemini CLI