Improve Codex CLI path fallback UX (#1205)

* Improve Codex CLI path fallback UX (#1193)

* Handle ENOENT Codex shim fallback
This commit is contained in:
shangxinyu1
2026-05-11 12:00:47 +08:00
committed by GitHub
parent 979733d39b
commit b19aa6c907
7 changed files with 495 additions and 21 deletions
+23 -6
View File
@@ -1077,19 +1077,36 @@ function configuredExecutableOverride(def, configuredEnv = {}) {
}
}
export function resolveAgentExecutable(def, configuredEnv = {}) {
if (!def?.bin) return null;
const configured = configuredExecutableOverride(def, configuredEnv);
if (configured) return configured;
export function inspectAgentExecutableResolution(def, configuredEnv = {}) {
if (!def?.bin) {
return {
configuredOverridePath: null,
pathResolvedPath: null,
selectedPath: null,
};
}
const configuredOverridePath = configuredExecutableOverride(def, configuredEnv);
const candidates = [
def.bin,
...(Array.isArray(def.fallbackBins) ? def.fallbackBins : []),
];
let pathResolvedPath = null;
for (const bin of candidates) {
const resolved = resolveOnPath(bin);
if (resolved) return resolved;
if (resolved) {
pathResolvedPath = resolved;
break;
}
}
return null;
return {
configuredOverridePath,
pathResolvedPath,
selectedPath: configuredOverridePath || pathResolvedPath,
};
}
export function resolveAgentExecutable(def, configuredEnv = {}) {
return inspectAgentExecutableResolution(def, configuredEnv).selectedPath;
}
async function fetchModels(def, resolvedBin, env) {
+162 -5
View File
@@ -22,6 +22,7 @@ import os from 'node:os';
import path from 'node:path';
import {
getAgentDef,
inspectAgentExecutableResolution,
resolveAgentBin,
spawnEnvForAgent,
} from './agents.js';
@@ -32,6 +33,7 @@ import { createClaudeStreamHandler } from './claude-stream.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import {
isLoopbackApiHost,
validateBaseUrl,
@@ -62,6 +64,56 @@ const SAMPLE_MAX_CHARS = 120;
const PROVIDER_MAX_TOKENS = 100;
const SMOKE_PROMPT = 'Reply with only: ok';
function codexExecutableGuidance(
agentId: string,
configuredOverridePath: string | null,
pathResolvedPath: string | null,
): string {
if (
agentId !== 'codex' ||
!configuredOverridePath ||
!pathResolvedPath ||
configuredOverridePath === pathResolvedPath
) {
return '';
}
return ` Configured Codex path failed: ${configuredOverridePath}. Open Design also detected a PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
}
function codexExecutableFallbackSuccessDetail(
configuredOverridePath: string,
pathResolvedPath: string,
): string {
return `Configured Codex path failed: ${configuredOverridePath}. This test succeeded with the PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
}
function codexConfiguredPathSuccessDetail(
configuredOverridePath: string,
): string {
return `This test used the configured Codex path: ${configuredOverridePath}.`;
}
function codexInvalidConfiguredPathFallbackDetail(
configuredValue: string,
pathResolvedPath: string,
): string {
return `Configured Codex path is invalid or not executable: ${configuredValue}. This test used the PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
}
function stripCodexBinOverride(
prefs: AgentCliEnvPrefs | undefined,
): AgentCliEnvPrefs | undefined {
if (!prefs?.codex?.CODEX_BIN) return prefs;
const nextCodex = { ...prefs.codex };
delete nextCodex.CODEX_BIN;
const next: AgentCliEnvPrefs = {
...prefs,
codex: nextCodex,
};
if (Object.keys(nextCodex).length === 0) delete next.codex;
return Object.keys(next).length > 0 ? next : undefined;
}
// Catches `Bearer …`, `x-api-key`/`api-key`/`x-goog-api-key` headers, and
// `?key=…` query strings. The provider helpers all funnel error text
// through this before logging; if a vendor surfaces the key in body text
@@ -862,7 +914,7 @@ function delay(ms: number): Promise<void> {
});
}
export async function testAgentConnection(
async function testAgentConnectionInternal(
input: AgentConnectionInput,
): Promise<ConnectionTestResponse> {
const start = Date.now();
@@ -885,6 +937,10 @@ export async function testAgentConnection(
validateAgentCliEnv(input.agentCliEnv),
input.agentId,
);
const executableResolution = inspectAgentExecutableResolution(
def,
configuredAgentEnv,
);
const resolvedBin = resolveAgentBin(input.agentId, configuredAgentEnv);
if (!resolvedBin) {
return {
@@ -1052,10 +1108,17 @@ export async function testAgentConnection(
if (winner.kind === 'spawnError') {
const latencyMs = Date.now() - start;
const detail = redactSecrets(winner.error.message);
const guidance = redactSecrets(
codexExecutableGuidance(
input.agentId,
executableResolution.configuredOverridePath,
executableResolution.pathResolvedPath,
),
);
const errnoCode = (winner.error as NodeJS.ErrnoException).code;
const isMissing = errnoCode === 'ENOENT';
console.warn(
`[test:agent] ${def.name} → spawn_failed: ${detail}`,
`[test:agent] ${def.name} → spawn_failed: ${detail}${guidance}`,
);
return {
ok: false,
@@ -1063,7 +1126,7 @@ export async function testAgentConnection(
latencyMs,
model,
agentName: def.name,
detail,
detail: `${detail}${guidance}`,
};
}
@@ -1089,9 +1152,16 @@ export async function testAgentConnection(
.filter(Boolean)
.join(' · '),
);
const guidance = redactSecrets(
codexExecutableGuidance(
input.agentId,
executableResolution.configuredOverridePath,
executableResolution.pathResolvedPath,
),
);
const label = buffered ? 'exit_failed' : 'no_text';
console.warn(
`[test:agent] ${def.name} → ${label} (${detail || 'no detail'})`,
`[test:agent] ${def.name} → ${label} (${detail || 'no detail'}${guidance})`,
);
return {
ok: false,
@@ -1099,7 +1169,8 @@ export async function testAgentConnection(
latencyMs,
model,
agentName: def.name,
detail: detail || 'Agent exited without producing assistant text',
detail:
`${detail || 'Agent exited without producing assistant text'}${guidance}`,
};
};
@@ -1203,3 +1274,89 @@ export async function testAgentConnection(
});
}
}
export async function testAgentConnection(
input: AgentConnectionInput,
): Promise<ConnectionTestResponse> {
const primaryResult = await testAgentConnectionInternal(input);
const validatedPrefs = validateAgentCliEnv(input.agentCliEnv);
const configuredCodexBin = validatedPrefs?.codex?.CODEX_BIN?.trim() || '';
const configuredAgentEnv = agentCliEnvForAgent(validatedPrefs, input.agentId);
const def = getAgentDef(input.agentId);
const executableResolution = def
? inspectAgentExecutableResolution(def, configuredAgentEnv)
: {
configuredOverridePath: null,
pathResolvedPath: null,
selectedPath: null,
};
if (
input.agentId === 'codex' &&
primaryResult.ok &&
configuredCodexBin
) {
if (executableResolution.configuredOverridePath) {
return {
...primaryResult,
configuredExecutablePath: executableResolution.configuredOverridePath,
usedExecutablePath: executableResolution.configuredOverridePath,
usedExecutableSource: 'configured',
...(executableResolution.pathResolvedPath
? { detectedExecutablePath: executableResolution.pathResolvedPath }
: {}),
detail: redactSecrets(
codexConfiguredPathSuccessDetail(
executableResolution.configuredOverridePath,
),
),
};
}
if (executableResolution.pathResolvedPath) {
return {
...primaryResult,
configuredExecutablePath: configuredCodexBin,
detectedExecutablePath: executableResolution.pathResolvedPath,
usedExecutablePath: executableResolution.pathResolvedPath,
usedExecutableSource: 'fallback_invalid',
detail: redactSecrets(
codexInvalidConfiguredPathFallbackDetail(
configuredCodexBin,
executableResolution.pathResolvedPath,
),
),
};
}
}
if (
input.agentId !== 'codex' ||
primaryResult.ok ||
!new Set<ConnectionTestKind>(['agent_spawn_failed', 'agent_not_installed', 'unknown']).has(primaryResult.kind) ||
!executableResolution.configuredOverridePath ||
!executableResolution.pathResolvedPath ||
executableResolution.configuredOverridePath === executableResolution.pathResolvedPath
) {
return primaryResult;
}
const fallbackResult = await testAgentConnectionInternal(
{
...input,
agentCliEnv: stripCodexBinOverride(validatedPrefs),
},
);
if (!fallbackResult.ok) {
return primaryResult;
}
return {
...fallbackResult,
configuredExecutablePath: executableResolution.configuredOverridePath,
detectedExecutablePath: executableResolution.pathResolvedPath,
usedExecutablePath: executableResolution.pathResolvedPath,
usedExecutableSource: 'fallback_failed',
detail: redactSecrets(
codexExecutableFallbackSuccessDetail(
executableResolution.configuredOverridePath,
executableResolution.pathResolvedPath,
),
),
};
}
+28
View File
@@ -16,6 +16,7 @@ import {
checkWindowsCmdShimCommandLineBudget,
checkWindowsDirectExeCommandLineBudget,
detectAgents,
inspectAgentExecutableResolution,
resolveAgentExecutable,
spawnEnvForAgent,
} from '../src/agents.js';
@@ -1953,6 +1954,33 @@ test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH r
}
});
test('inspectAgentExecutableResolution reports configured and PATH Codex binaries separately', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-inspect-'));
try {
const configured = join(dir, 'codex-custom');
const fallback = join(dir, 'codex');
writeFileSync(configured, '#!/bin/sh\nexit 0\n');
writeFileSync(fallback, '#!/bin/sh\nexit 0\n');
chmodSync(configured, 0o755);
chmodSync(fallback, 0o755);
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const resolution = inspectAgentExecutableResolution(
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
);
assert.deepEqual(resolution, {
configuredOverridePath: configured,
pathResolvedPath: fallback,
selectedPath: configured,
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable supports configured binary overrides for non-Codex adapters', () => {
const cases: Array<[string, string, string]> = [
['claude', 'claude', 'CLAUDE_BIN'],
+122
View File
@@ -1223,13 +1223,135 @@ console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_messag
ok: true,
kind: 'success',
agentName: 'Codex CLI',
usedExecutableSource: 'configured',
configuredExecutablePath: bin,
usedExecutablePath: bin,
});
expect(result.detail).toContain(`This test used the configured Codex path: ${bin}.`);
} finally {
process.env.PATH = oldPath;
await fsp.rm(dir, { recursive: true, force: true });
}
});
it('surfaces when an invalid configured CODEX_BIN was ignored in favor of PATH', async () => {
await withFakeCodex(
`console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`,
async () => {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-invalid-'));
try {
const invalidBin = path.join(dir, 'codex-missing');
const result = await testAgentConnection({
agentId: 'codex',
agentCliEnv: {
codex: {
CODEX_BIN: invalidBin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Codex CLI',
sample: 'ok',
usedExecutableSource: 'fallback_invalid',
configuredExecutablePath: invalidBin,
detectedExecutablePath: expect.any(String),
usedExecutablePath: expect.any(String),
});
expect(result.detail).toContain(`Configured Codex path is invalid or not executable: ${invalidBin}.`);
expect(result.detail).toContain('This test used the PATH Codex CLI at');
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
},
);
});
it('falls back to PATH Codex during connection tests when a configured CODEX_BIN fails', async () => {
await withFakeCodex(
`console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`,
async () => {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-fallback-'));
try {
const bin = path.join(dir, 'codex-bad');
await fsp.writeFile(
bin,
`#!/usr/bin/env node\nconsole.error('macOS blocked this Codex binary');\nprocess.exit(1);\n`,
);
await fsp.chmod(bin, 0o755);
const result = await testAgentConnection({
agentId: 'codex',
agentCliEnv: {
codex: {
CODEX_BIN: bin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Codex CLI',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: bin,
detectedExecutablePath: expect.any(String),
usedExecutablePath: expect.any(String),
});
expect(result.detail).toContain(`Configured Codex path failed: ${bin}.`);
expect(result.detail).toContain('This test succeeded with the PATH Codex CLI at');
expect(result.detail).toContain('Update CODEX_BIN or clear the custom path');
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
},
);
});
it('falls back to PATH Codex when a configured shim spawns ENOENT', async () => {
await withFakeCodex(
`console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`,
async () => {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-stale-shim-'));
try {
const bin = path.join(dir, 'codex-stale-shim');
await fsp.writeFile(
bin,
'#!/definitely/missing/node\nconsole.log("never runs");\n',
);
await fsp.chmod(bin, 0o755);
const result = await testAgentConnection({
agentId: 'codex',
agentCliEnv: {
codex: {
CODEX_BIN: bin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Codex CLI',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: bin,
detectedExecutablePath: expect.any(String),
usedExecutablePath: expect.any(String),
});
expect(result.detail).toContain(`Configured Codex path failed: ${bin}.`);
expect(result.detail).toContain('This test succeeded with the PATH Codex CLI at');
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
},
);
});
it('reports OpenCode structured errors without treating them as raw output', async () => {
await withFakeOpenCode(
`
+138 -10
View File
@@ -239,6 +239,44 @@ const API_PROTOCOL_LABELS: Record<ApiProtocol, string> = {
ollama: 'Ollama Cloud API',
};
function codexPathStrings(locale: Locale) {
if (locale === 'zh-CN') {
return {
repairHint: '当前保存的 Codex 路径不适合继续使用。',
useDetected: '使用检测到的 Codex',
clearCustom: '清空自定义路径',
configuredSuccess: (path: string) => `本次测试使用的是已配置的 Codex 路径:${path}。`,
invalidFallback: (configuredPath: string, detectedPath: string) =>
`已配置的 Codex 路径无效或不可执行:${configuredPath}。本次测试改用 PATH 中的 Codex CLI:${detectedPath}。建议更新 CODEX_BIN 或清空自定义路径。`,
failedFallback: (configuredPath: string, detectedPath: string) =>
`已配置的 Codex 路径启动失败:${configuredPath}。本次测试改用 PATH 中的 Codex CLI:${detectedPath}。建议更新 CODEX_BIN 或清空自定义路径。`,
};
}
if (locale === 'zh-TW') {
return {
repairHint: '目前儲存的 Codex 路徑不適合繼續使用。',
useDetected: '使用偵測到的 Codex',
clearCustom: '清除自訂路徑',
configuredSuccess: (path: string) => `本次測試使用的是已設定的 Codex 路徑:${path}。`,
invalidFallback: (configuredPath: string, detectedPath: string) =>
`已設定的 Codex 路徑無效或不可執行:${configuredPath}。本次測試改用 PATH 中的 Codex CLI:${detectedPath}。建議更新 CODEX_BIN 或清除自訂路徑。`,
failedFallback: (configuredPath: string, detectedPath: string) =>
`已設定的 Codex 路徑啟動失敗:${configuredPath}。本次測試改用 PATH 中的 Codex CLI:${detectedPath}。建議更新 CODEX_BIN 或清除自訂路徑。`,
};
}
return {
repairHint: 'The saved Codex path is not the binary this test should keep using.',
useDetected: 'Use detected Codex',
clearCustom: 'Clear custom path',
configuredSuccess: (path: string) =>
`This test used the configured Codex path: ${path}.`,
invalidFallback: (configuredPath: string, detectedPath: string) =>
`Configured Codex path is invalid or not executable: ${configuredPath}. This test used the PATH Codex CLI at ${detectedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`,
failedFallback: (configuredPath: string, detectedPath: string) =>
`Configured Codex path failed: ${configuredPath}. This test succeeded with the PATH Codex CLI at ${detectedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`,
};
}
function sanitizeHttpsUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
@@ -539,6 +577,24 @@ function apiModelOptionLabel(model: ProviderModelOption): string {
: model.id;
}
function codexPathRepairState(
result: ConnectionTestResponse,
): { detectedPath: string; canUseDetected: boolean } | null {
if (!result.ok) return null;
if (
result.usedExecutableSource !== 'fallback_invalid' &&
result.usedExecutableSource !== 'fallback_failed'
) {
return null;
}
const detectedPath = result.detectedExecutablePath?.trim() || '';
if (!detectedPath) return null;
return {
detectedPath,
canUseDetected: true,
};
}
/**
* Returns whether the modal's footer Save button should be enabled for the
* currently active sidebar section.
@@ -1034,9 +1090,39 @@ export function SettingsDialog({
const agentName = result.agentName ?? '';
const testedModel = result.model ?? cfg.model;
if (result.ok) {
return kindForSuccess === 'api'
const baseMessage = kindForSuccess === 'api'
? t('settings.testSuccessApi', { ms, sample })
: t('settings.testSuccessCli', { agentName, ms, sample });
if (kindForSuccess === 'cli' && cfg.agentId === 'codex') {
const codexStrings = codexPathStrings(locale);
if (
result.usedExecutableSource === 'configured' &&
result.configuredExecutablePath
) {
return `${baseMessage} ${codexStrings.configuredSuccess(result.configuredExecutablePath)}`;
}
if (
result.usedExecutableSource === 'fallback_invalid' &&
result.configuredExecutablePath &&
result.detectedExecutablePath
) {
return `${baseMessage} ${codexStrings.invalidFallback(
result.configuredExecutablePath,
result.detectedExecutablePath,
)}`;
}
if (
result.usedExecutableSource === 'fallback_failed' &&
result.configuredExecutablePath &&
result.detectedExecutablePath
) {
return `${baseMessage} ${codexStrings.failedFallback(
result.configuredExecutablePath,
result.detectedExecutablePath,
)}`;
}
}
return result.detail ? `${baseMessage} ${result.detail}` : baseMessage;
}
switch (result.kind) {
case 'auth_failed':
@@ -1099,6 +1185,16 @@ export function SettingsDialog({
}
};
const applyCodexDetectedPath = (detectedPath: string) => {
setCfg((c) => updateAgentCliEnvValue(c, 'codex', 'CODEX_BIN', detectedPath));
setAgentTestState({ status: 'idle' });
};
const clearCodexCustomPath = () => {
setCfg((c) => updateAgentCliEnvValue(c, 'codex', 'CODEX_BIN', ''));
setAgentTestState({ status: 'idle' });
};
const apiProtocol = cfg.apiProtocol ?? 'anthropic';
const baseUrlValid = isValidApiBaseUrl(cfg.baseUrl);
const baseUrlInvalid = Boolean(cfg.baseUrl.trim() && !baseUrlValid);
@@ -1716,15 +1812,47 @@ export function SettingsDialog({
{t('settings.testRunning')}
</p>
) : agentTestState.status === 'done' ? (
<p
className={
'settings-test-status ' +
testStatusVariant(agentTestState.result)
}
role={agentTestState.result.ok ? 'status' : 'alert'}
>
{renderTestMessage(agentTestState.result, 'cli')}
</p>
<>
<p
className={
'settings-test-status ' +
testStatusVariant(agentTestState.result)
}
role={agentTestState.result.ok ? 'status' : 'alert'}
>
{renderTestMessage(agentTestState.result, 'cli')}
</p>
{cfg.agentId === 'codex' && (() => {
const repair = codexPathRepairState(agentTestState.result);
if (!repair) return null;
const codexStrings = codexPathStrings(locale);
return (
<div className="settings-test-actions">
<span className="settings-test-actions-hint">
{codexStrings.repairHint}
</span>
<div className="settings-test-actions-row">
{repair.canUseDetected ? (
<button
type="button"
className="settings-test-btn"
onClick={() => applyCodexDetectedPath(repair.detectedPath)}
>
{codexStrings.useDetected}
</button>
) : null}
<button
type="button"
className="ghost icon-btn settings-rescan-btn"
onClick={clearCodexCustomPath}
>
{codexStrings.clearCustom}
</button>
</div>
</div>
);
})()}
</>
) : null}
{agents.length === 0 ? (
<div className="empty-card">
+15
View File
@@ -1941,6 +1941,21 @@ code {
background: var(--accent-tint);
color: var(--accent-strong);
}
.settings-test-actions {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-test-actions-hint {
font-size: 12px;
color: var(--text-muted);
}
.settings-test-actions-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-test-btn {
display: inline-flex;
align-items: center;
@@ -170,4 +170,11 @@ export interface ConnectionTestResponse {
// Free-form, redacted detail line — surfaced in the `unknown`,
// `agent_spawn_failed`, and `upstream_unavailable` copy.
detail?: string;
// Optional executable-path diagnostics for Local CLI tests. Used by
// Settings to explain whether a saved custom path worked, was ignored,
// or required a PATH fallback.
configuredExecutablePath?: string;
detectedExecutablePath?: string;
usedExecutablePath?: string;
usedExecutableSource?: 'configured' | 'path' | 'fallback_invalid' | 'fallback_failed';
}