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

The OpenAI Codex desktop app ships the real `codex` CLI inside its
`.app` bundle but does not symlink it onto PATH. Users who installed
only the desktop app therefore failed PATH-based detection, so the
heterogeneous agent never spawned Codex and silently produced no reply.

Probe the bundled binary as a fallback candidate (after the PATH lookup,
so a user's own install still wins), covering both system and per-user
install locations. macOS only for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-13 11:48:13 +08:00
parent f60d1fe8dd
commit e20a669394
2 changed files with 60 additions and 2 deletions
@@ -182,6 +182,42 @@ describe('cliAgentDetectors', () => {
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it('falls back to the bundled Codex.app binary when `codex` is not on PATH', async () => {
// OpenAI's Codex desktop app ships the real CLI inside the .app but does
// not symlink it onto PATH. A user with only the desktop app installed
// has no `codex` on PATH, so detection must fall back to the bundle.
const originalShell = process.env.SHELL;
const originalPath = process.env.PATH;
// Unset SHELL and pin an already-normalized PATH so `resolveCommandPath`
// makes exactly one `which` attempt (no login-shell / normalized-PATH
// fallback retry), leaving the bundle as the only viable candidate.
delete process.env.SHELL;
process.env.PATH = '/usr/bin:/bin';
try {
// 1) `which codex` → not found
callExecFileError(new Error('not found'));
// 2) absolute bundle path resolves directly (no `which`); validate it
callExecFile('codex-cli 0.138.0-alpha.7');
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-alpha.7');
// The validation call must target the bundled binary directly via execFile.
expect(execMock).not.toHaveBeenCalled();
const validateCall = execFileMock.mock.calls.at(-1)!;
expect(validateCall[0]).toBe('/Applications/Codex.app/Contents/Resources/codex');
expect(validateCall[1]).toEqual(['--version']);
} finally {
process.env.SHELL = originalShell;
process.env.PATH = originalPath;
}
});
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
@@ -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';
@@ -258,12 +258,34 @@ export const claudeCodeDetector: IToolDetector = createValidatedDetector({
validateKeywords: ['claude code'],
});
/**
* OpenAI's Codex desktop app bundles the real `codex` CLI inside its `.app`
* but does not symlink it onto PATH. A user who installed only the desktop app
* would therefore fail PATH-based detection, so we probe the bundled binary as
* a fallback (tried after the PATH lookup, so a user's own install still wins).
* Both the system (`/Applications`) and per-user (`~/Applications`) install
* locations are covered. macOS only for now.
*/
const getCodexCandidates = (): string[] => {
const candidates = ['codex'];
if (platform() === 'darwin') {
const bundleRelativePath = 'Codex.app/Contents/Resources/codex';
candidates.push(
path.join('/Applications', bundleRelativePath),
path.join(homedir(), 'Applications', bundleRelativePath),
);
}
return candidates;
};
/**
* OpenAI Codex CLI
* @see https://github.com/openai/codex
*/
export const codexDetector: IToolDetector = createValidatedDetector({
candidates: ['codex'],
candidates: getCodexCandidates(),
description: 'Codex - OpenAI agentic coding CLI',
name: 'codex',
priority: 2,