mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 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:
@@ -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(
|
||||||
(
|
(
|
||||||
|
|||||||
Reference in New Issue
Block a user