Compare commits

...

3 Commits

Author SHA1 Message Date
Arvin Xu 87b02119f1 🐛 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>
2026-06-13 12:56:17 +08:00
Arvin Xu c8c29b997f 💄 style(desktop): hint to install gh CLI when PR status unavailable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:53:10 +08:00
Arvin Xu e20a669394 🐛 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>
2026-06-13 11:49:23 +08:00
5 changed files with 142 additions and 15 deletions
@@ -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<void>;
@@ -451,16 +461,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return relevantStderr || `Agent exited with code ${code}`;
}
private async getSpawnPreflightError(
session: AgentSession,
): Promise<HeterogeneousAgentSessionError | undefined> {
private async runSpawnPreflight(session: AgentSession): Promise<SpawnPreflightResult> {
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:',
@@ -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(
(
@@ -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,
@@ -1,6 +1,6 @@
import { Icon, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GitPullRequest } from 'lucide-react';
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GitPullRequest, InfoIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -59,6 +59,21 @@ const styles = createStaticStyles(({ css }) => {
diffStatModified: css`
color: ${cssVar.colorWarning};
`,
ghMissingHint: css`
display: flex;
flex: none;
align-items: center;
padding-inline: 2px;
color: ${cssVar.colorTextQuaternary};
transition: color 0.2s;
&:hover {
color: ${cssVar.colorTextTertiary};
}
`,
prTrigger: css`
cursor: pointer;
@@ -393,7 +408,7 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
{pullNode}
{pushNode}
{diffNode}
{data.pullRequest && (
{data.pullRequest ? (
<>
<div className={styles.separator} />
<Tooltip title={prTooltip}>
@@ -403,6 +418,14 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
</div>
</Tooltip>
</>
) : (
data.ghMissing && (
<Tooltip title={prTooltip}>
<div className={styles.ghMissingHint}>
<Icon icon={InfoIcon} size={12} />
</div>
</Tooltip>
)
)}
</>
);