This commit is contained in:
kami 2026-05-31 10:15:07 +00:00 committed by GitHub
commit 1b92dc9f80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2607 additions and 153 deletions

View file

@ -7,6 +7,7 @@ export {
export { detectAgents } from './runtimes/detection.js';
export {
resolveOnPath,
inspectAgentExecutableCandidates,
inspectAgentExecutableResolution,
resolveAgentExecutable,
} from './runtimes/executables.js';

View file

@ -26,6 +26,7 @@ import type { Dispatcher, Pool } from 'undici';
import {
applyAgentLaunchEnv,
getAgentDef,
inspectAgentExecutableCandidates,
resolveAgentLaunch,
spawnEnvForAgent,
} from './agents.js';
@ -52,8 +53,13 @@ import {
buildOpenAIChatTokenParam,
isUnsupportedMaxTokensError,
} from './openai-chat-token-params.js';
import { execAgentFile } from './runtimes/invocation.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import type {
RuntimeAgentDef,
RuntimeExecutableCandidate,
RuntimeExecutableCandidateSource,
} from './runtimes/types.js';
import { resolveModelForAgent } from './runtimes/models.js';
import {
isBlockedExternalApiHostname,
@ -576,56 +582,208 @@ function formatPromptForAgentStdin(
return prompt;
}
function codexExecutableGuidance(
function agentBinEnvKey(agentId: string): string | null {
if (agentId === 'codex') return 'CODEX_BIN';
if (agentId === 'opencode') return 'OPENCODE_BIN';
return null;
}
function agentExecutableLabel(agentId: string): string {
if (agentId === 'opencode') return 'OpenCode';
if (agentId === 'codex') return 'Codex';
return agentId;
}
function agentExecutableGuidance(
agentId: string,
configuredOverridePath: string | null,
pathResolvedPath: string | null,
pathResolvedSource: RuntimeExecutableCandidateSource | null = null,
): string {
if (
agentId !== 'codex' ||
!agentBinEnvKey(agentId) ||
!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.`;
const label = agentExecutableLabel(agentId);
const envKey = agentBinEnvKey(agentId);
return ` Configured ${label} path failed: ${configuredOverridePath}. Open Design also detected ${agentExecutableSourceLabel(agentId, pathResolvedSource)} at ${pathResolvedPath}. Update ${envKey} or clear the custom path to use the detected binary.`;
}
function codexExecutableFallbackSuccessDetail(
function agentExecutableFallbackSuccessDetail(
agentId: string,
configuredOverridePath: string,
pathResolvedPath: string,
pathResolvedSource: RuntimeExecutableCandidateSource | null = null,
): 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.`;
const label = agentExecutableLabel(agentId);
const envKey = agentBinEnvKey(agentId) ?? 'custom path';
return `Configured ${label} path failed: ${configuredOverridePath}. This test succeeded with ${agentExecutableSourceLabel(agentId, pathResolvedSource)} at ${pathResolvedPath}. Update ${envKey} or clear the custom path to use the detected binary.`;
}
function codexConfiguredPathSuccessDetail(
function agentExecutableRetrySuccessDetail(
agentId: string,
failedPath: string,
pathResolvedPath: string,
pathResolvedSource: RuntimeExecutableCandidateSource | null = null,
): string {
const label = agentExecutableLabel(agentId);
const envKey = agentBinEnvKey(agentId);
const guidance = envKey
? ` Set ${envKey} to this path to keep using it.`
: '';
return `${label} binary failed: ${failedPath}. This test succeeded with ${agentExecutableSourceLabel(agentId, pathResolvedSource)} at ${pathResolvedPath}.${guidance}`;
}
function agentConfiguredPathSuccessDetail(
agentId: string,
configuredOverridePath: string,
): string {
return `This test used the configured Codex path: ${configuredOverridePath}.`;
return `This test used the configured ${agentExecutableLabel(agentId)} path: ${configuredOverridePath}.`;
}
function codexInvalidConfiguredPathFallbackDetail(
function agentInvalidConfiguredPathFallbackDetail(
agentId: string,
configuredValue: string,
pathResolvedPath: string,
pathResolvedSource: RuntimeExecutableCandidateSource | null = null,
): 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.`;
const label = agentExecutableLabel(agentId);
const envKey = agentBinEnvKey(agentId) ?? 'custom path';
return `Configured ${label} path is invalid or not executable: ${configuredValue}. This test used ${agentExecutableSourceLabel(agentId, pathResolvedSource)} at ${pathResolvedPath}. Update ${envKey} or clear the custom path to use the detected binary.`;
}
function stripCodexBinOverride(
function agentExecutableSourceLabel(
agentId: string,
source: RuntimeExecutableCandidateSource | null,
): string {
const label = agentExecutableLabel(agentId);
switch (source) {
case 'known':
return `the known ${label} install`;
case 'configured':
return `the configured ${label} path`;
case 'fallback':
case 'path':
default:
return `the PATH ${label} CLI`;
}
}
function stripAgentBinOverride(
prefs: AgentCliEnvPrefs | undefined,
agentId: string,
): AgentCliEnvPrefs | undefined {
if (!prefs?.codex?.CODEX_BIN) return prefs;
const nextCodex = { ...prefs.codex };
delete nextCodex.CODEX_BIN;
const envKey = agentBinEnvKey(agentId);
if (!envKey || !prefs?.[agentId]?.[envKey]) return prefs;
const nextAgent = { ...prefs[agentId] };
delete nextAgent[envKey];
const next: AgentCliEnvPrefs = {
...prefs,
codex: nextCodex,
[agentId]: nextAgent,
};
if (Object.keys(nextCodex).length === 0) delete next.codex;
if (Object.keys(nextAgent).length === 0) delete next[agentId];
return Object.keys(next).length > 0 ? next : undefined;
}
function setAgentBinOverride(
prefs: AgentCliEnvPrefs | undefined,
agentId: string,
executablePath: string,
): AgentCliEnvPrefs | undefined {
const envKey = agentBinEnvKey(agentId);
if (!envKey) return prefs;
return {
...(prefs ?? {}),
[agentId]: {
...(prefs?.[agentId] ?? {}),
[envKey]: executablePath,
},
};
}
function firstDistinctFallbackCandidate(
candidates: RuntimeExecutableCandidate[],
...excludedPaths: Array<string | null | undefined>
): RuntimeExecutableCandidate | null {
const excluded = new Set(excludedPaths.filter((value): value is string => Boolean(value)));
return candidates.find(
(candidate) =>
candidate.source !== 'configured' &&
!excluded.has(candidate.path),
) ?? null;
}
function executableCandidateSource(
candidates: RuntimeExecutableCandidate[],
executablePath: string | null | undefined,
): RuntimeExecutableCandidateSource | null {
if (!executablePath) return null;
return candidates.find((candidate) => candidate.path === executablePath)?.source ?? null;
}
function executableDiagnosticSource(
source: RuntimeExecutableCandidateSource | null,
): 'path' | 'known' | null {
if (source === 'known') return source;
if (source === 'path' || source === 'fallback') return 'path';
return null;
}
function usedExecutableDetail(
agentId: string,
executablePath: string | undefined,
version: string | null,
): string {
if (!executablePath) return '';
const versionSuffix = version ? ` (${version})` : '';
return `Used ${agentExecutableLabel(agentId)} binary: ${executablePath}${versionSuffix}.`;
}
async function probeExecutableVersionForDetail(
def: RuntimeAgentDef | null | undefined,
executablePath: string | undefined,
configuredAgentEnv: Record<string, string>,
): Promise<string | null> {
if (!def || !executablePath || !def.versionArgs?.length) return null;
const baseEnv = spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredAgentEnv,
);
const env = applyAgentLaunchEnv(baseEnv, {
childPathPrepend: path.isAbsolute(executablePath)
? [path.dirname(executablePath)]
: [],
});
try {
const { stdout } = await execAgentFile(executablePath, def.versionArgs, {
env,
timeout: 3000,
});
return String(stdout).trim().split('\n')[0] || null;
} catch {
return null;
}
}
function shouldAppendExecutableFailureDetail(
result: ConnectionTestResponse,
agentId: string,
detail: string,
): boolean {
if (!detail || result.ok || agentId !== 'opencode') return false;
if (result.kind !== 'agent_spawn_failed') return false;
const rawDetail = result.detail ?? '';
return /\bexit\s+\d+\b/.test(rawDetail) || rawDetail.includes('stderr:');
}
// 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
@ -652,6 +810,22 @@ export function redactSecrets(
type ProviderConnectionInput = ProviderTestRequest & { signal?: AbortSignal };
type AgentConnectionInput = AgentTestRequest & { signal?: AbortSignal };
type AgentConnectionInternalInput = AgentConnectionInput & {
executableFailureDetail?: string;
timeoutMs?: number;
};
function remainingTimeoutMs(deadlineMs: number): number {
return Math.max(0, deadlineMs - Date.now());
}
function fallbackAttemptTimeoutMs(
remainingMs: number,
remainingAttempts: number,
): number {
if (remainingAttempts <= 1) return Math.max(1, remainingMs);
return Math.max(1, Math.floor(remainingMs / remainingAttempts));
}
function appendVersionedApiPath(baseUrl: string, suffix: string): string {
const url = new URL(baseUrl);
@ -1641,7 +1815,7 @@ function delay(ms: number): Promise<void> {
}
async function testAgentConnectionInternal(
input: AgentConnectionInput,
input: AgentConnectionInternalInput,
): Promise<ConnectionTestResponse> {
const start = Date.now();
const model =
@ -1769,7 +1943,10 @@ async function testAgentConnectionInternal(
const resultFromStreamError = (error: unknown): ConnectionTestResponse => {
const latencyMs = Date.now() - start;
const detail = redactSecrets(
error instanceof Error ? error.message : String(error),
[
error instanceof Error ? error.message : String(error),
input.executableFailureDetail,
].filter(Boolean).join(' '),
);
const auth = classifyAgentAuthFailure(input.agentId, detail);
if (auth?.status === 'missing') {
@ -1935,10 +2112,14 @@ async function testAgentConnectionInternal(
const latencyMs = Date.now() - start;
const detail = redactSecrets(winner.error.message);
const guidance = redactSecrets(
`${codexExecutableGuidance(
`${agentExecutableGuidance(
input.agentId,
executableResolution.configuredOverridePath,
executableResolution.pathResolvedPath,
executableCandidateSource(
executableResolution.executableCandidates,
executableResolution.pathResolvedPath,
),
)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`,
);
const errnoCode = (winner.error as NodeJS.ErrnoException).code;
@ -2048,14 +2229,16 @@ async function testAgentConnectionInternal(
}),
};
}
const detail = redactSecrets(
rawDetail,
);
const detail = redactSecrets(rawDetail);
const guidance = redactSecrets(
`${codexExecutableGuidance(
`${agentExecutableGuidance(
input.agentId,
executableResolution.configuredOverridePath,
executableResolution.pathResolvedPath,
executableCandidateSource(
executableResolution.executableCandidates,
executableResolution.pathResolvedPath,
),
)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`,
);
const label = buffered ? 'exit_failed' : 'no_text';
@ -2069,7 +2252,13 @@ async function testAgentConnectionInternal(
model,
agentName: def.name,
detail:
`${detail || 'Agent exited without producing assistant text'}${guidance}`,
`${
[
detail || 'Agent exited without producing assistant text',
guidance,
input.executableFailureDetail,
].filter(Boolean).join(' ')
}`,
diagnostics: buildDiagnostics({
phase: buffered ? 'output_parse' : 'spawn',
exitCode: winner.code,
@ -2089,7 +2278,10 @@ async function testAgentConnectionInternal(
child.stdin.end(formatPromptForAgentStdin(def, SMOKE_PROMPT), 'utf8');
}
const cancellationPromise = new Promise<{ kind: 'timeout' } | { kind: 'aborted' }>((resolve) => {
timer = setTimeout(() => resolve({ kind: 'timeout' }), agentTimeoutMs());
timer = setTimeout(
() => resolve({ kind: 'timeout' }),
Math.max(1, input.timeoutMs ?? agentTimeoutMs()),
);
abortHandler = () => resolve({ kind: 'aborted' });
if (input.signal?.aborted) {
abortHandler();
@ -2187,9 +2379,17 @@ async function testAgentConnectionInternal(
export async function testAgentConnection(
input: AgentConnectionInput,
): Promise<ConnectionTestResponse> {
const primaryResult = await testAgentConnectionInternal(input);
const connectionTestDeadlineMs = Date.now() + agentTimeoutMs();
const primaryResult = await testAgentConnectionInternal({
...input,
timeoutMs: remainingTimeoutMs(connectionTestDeadlineMs),
});
const validatedPrefs = validateAgentCliEnv(input.agentCliEnv);
const configuredCodexBin = validatedPrefs?.codex?.CODEX_BIN?.trim() || '';
const envKey = agentBinEnvKey(input.agentId);
const configuredAgentBin =
envKey && validatedPrefs?.[input.agentId]?.[envKey]?.trim()
? validatedPrefs[input.agentId]![envKey]!.trim()
: '';
const configuredAgentEnv = agentCliEnvForAgent(validatedPrefs, input.agentId);
const def = getAgentDef(input.agentId);
const executableResolution = def
@ -2202,12 +2402,98 @@ export async function testAgentConnection(
launchKind: 'selected' as const,
childPathPrepend: [],
diagnostic: null,
executableCandidates: [],
};
if (
input.agentId === 'codex' &&
primaryResult.ok &&
configuredCodexBin
) {
const usedExecutablePath = executableResolution.launchPath ?? executableResolution.selectedPath ?? undefined;
const fallbackCandidate = executableResolution.configuredOverridePath
? firstDistinctFallbackCandidate(
executableResolution.executableCandidates,
executableResolution.configuredOverridePath,
)
: firstDistinctFallbackCandidate(
executableResolution.executableCandidates,
usedExecutablePath,
executableResolution.selectedPath,
executableResolution.pathResolvedPath,
)
?? (def
? firstDistinctFallbackCandidate(
inspectAgentExecutableCandidates(def, configuredAgentEnv),
usedExecutablePath,
executableResolution.selectedPath,
executableResolution.pathResolvedPath,
)
: null);
const pathExecutablePath = fallbackCandidate?.path
?? executableResolution.pathResolvedPath
?? null;
const pathExecutableSource = executableCandidateSource(
executableResolution.executableCandidates,
pathExecutablePath,
);
const pathExecutableDiagnosticSource = executableDiagnosticSource(pathExecutableSource);
const usedExecutableVersion =
executableResolution.executableCandidates?.find(
(candidate) => candidate.path === usedExecutablePath,
)?.version ?? null;
const usedExecutableCandidateSource = executableCandidateSource(
executableResolution.executableCandidates,
usedExecutablePath,
);
const usedExecutableDiagnosticSource = executableDiagnosticSource(
usedExecutableCandidateSource,
);
let executableFailureDetail = usedExecutableDetail(
input.agentId,
usedExecutablePath,
usedExecutableVersion,
);
const withUsedExecutableDiagnostics = (
result: ConnectionTestResponse,
): ConnectionTestResponse => ({
...result,
...(executableResolution.configuredOverridePath
? { configuredExecutablePath: executableResolution.configuredOverridePath }
: {}),
...(pathExecutablePath
? {
detectedExecutablePath: pathExecutablePath,
...(pathExecutableDiagnosticSource
? { detectedExecutableSource: pathExecutableDiagnosticSource }
: {}),
}
: {}),
...(usedExecutablePath ? { usedExecutablePath } : {}),
...(usedExecutablePath
? {
usedExecutableSource:
executableResolution.configuredOverridePath &&
usedExecutablePath === executableResolution.configuredOverridePath
? 'configured'
: usedExecutableDiagnosticSource ?? 'path',
}
: {}),
});
const executableFailureKinds = new Set<ConnectionTestKind>([
'agent_spawn_failed',
'agent_not_installed',
'unknown',
]);
const supportsExecutableFallback = Boolean(agentBinEnvKey(input.agentId));
const fallbackFailedExecutablePath =
executableResolution.configuredOverridePath
?? usedExecutablePath
?? executableResolution.selectedPath
?? executableResolution.pathResolvedPath
?? null;
const canRetryExecutableFallback = Boolean(
pathExecutablePath &&
fallbackFailedExecutablePath &&
pathExecutablePath !== fallbackFailedExecutablePath &&
(executableResolution.configuredOverridePath || input.agentId === 'opencode'),
);
if (primaryResult.ok && configuredAgentBin) {
if (executableResolution.configuredOverridePath) {
return {
...primaryResult,
@ -2215,61 +2501,215 @@ export async function testAgentConnection(
usedExecutablePath: executableResolution.launchPath ?? executableResolution.configuredOverridePath,
usedExecutableSource: 'configured',
...(executableResolution.pathResolvedPath
? { detectedExecutablePath: executableResolution.pathResolvedPath }
? {
detectedExecutablePath: executableResolution.pathResolvedPath,
...(pathExecutableDiagnosticSource
? { detectedExecutableSource: pathExecutableDiagnosticSource }
: {}),
}
: {}),
detail: redactSecrets(
codexConfiguredPathSuccessDetail(
agentConfiguredPathSuccessDetail(
input.agentId,
executableResolution.configuredOverridePath,
),
),
};
}
if (executableResolution.pathResolvedPath) {
const invalidConfiguredFallbackPath =
executableResolution.selectedPath ?? executableResolution.pathResolvedPath;
if (invalidConfiguredFallbackPath) {
const invalidConfiguredFallbackSource = executableDiagnosticSource(
executableCandidateSource(
executableResolution.executableCandidates,
invalidConfiguredFallbackPath,
),
);
return {
...primaryResult,
configuredExecutablePath: configuredCodexBin,
detectedExecutablePath: executableResolution.pathResolvedPath,
usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
configuredExecutablePath: configuredAgentBin,
detectedExecutablePath: invalidConfiguredFallbackPath,
...(invalidConfiguredFallbackSource
? { detectedExecutableSource: invalidConfiguredFallbackSource }
: {}),
usedExecutablePath:
executableResolution.launchPath ?? invalidConfiguredFallbackPath,
usedExecutableSource: 'fallback_invalid',
detail: redactSecrets(
codexInvalidConfiguredPathFallbackDetail(
configuredCodexBin,
executableResolution.pathResolvedPath,
agentInvalidConfiguredPathFallbackDetail(
input.agentId,
configuredAgentBin,
invalidConfiguredFallbackPath,
invalidConfiguredFallbackSource,
),
),
};
}
}
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
!executableFailureKinds.has(primaryResult.kind) ||
!supportsExecutableFallback ||
!canRetryExecutableFallback
) {
if (
!primaryResult.ok &&
executableFailureKinds.has(primaryResult.kind) &&
supportsExecutableFallback &&
shouldAppendExecutableFailureDetail(
primaryResult,
input.agentId,
executableFailureDetail,
)
) {
if (!usedExecutableVersion && input.agentId === 'opencode') {
executableFailureDetail = usedExecutableDetail(
input.agentId,
usedExecutablePath,
await probeExecutableVersionForDetail(
def,
usedExecutablePath,
configuredAgentEnv,
),
);
}
const detail = [
primaryResult.detail ?? '',
executableFailureDetail,
].filter(Boolean).join(' ');
return withUsedExecutableDiagnostics({
...primaryResult,
detail: redactSecrets(detail),
});
}
return primaryResult;
}
const fallbackResult = await testAgentConnectionInternal(
{
...input,
agentCliEnv: stripCodexBinOverride(validatedPrefs),
},
// Iterate every remaining non-configured candidate in order so the
// recovery flow doesn't fail closed on the first invocable-but-broken
// alternate. `inspectAgentExecutableCandidates()` only proves a file is
// version-probeable, not that the full smoke test succeeds; a realistic
// stale-wrapper layout is configured-binary-fails / first-PATH-candidate-
// fails-smoke-prompt / later-known-install-succeeds. We must exercise
// each alternate via `testAgentConnectionInternal()` until one passes or
// we run out of candidates.
const fallbackCandidates = collectFallbackCandidates(
executableResolution.executableCandidates,
executableResolution.configuredOverridePath
? [executableResolution.configuredOverridePath]
: [
usedExecutablePath,
executableResolution.selectedPath,
executableResolution.pathResolvedPath,
],
pathExecutablePath,
def,
configuredAgentEnv,
);
if (!fallbackResult.ok) {
return primaryResult;
for (let i = 0; i < fallbackCandidates.length; i += 1) {
const candidatePath = fallbackCandidates[i]!;
const remainingMs = remainingTimeoutMs(connectionTestDeadlineMs);
if (remainingMs <= 0) break;
const fallbackResult = await testAgentConnectionInternal(
{
...input,
agentCliEnv: setAgentBinOverride(
stripAgentBinOverride(validatedPrefs, input.agentId),
input.agentId,
candidatePath,
),
executableFailureDetail,
timeoutMs: fallbackAttemptTimeoutMs(
remainingMs,
fallbackCandidates.length - i,
),
},
);
if (!fallbackResult.ok) continue;
const candidateSource = executableCandidateSource(
executableResolution.executableCandidates,
candidatePath,
);
const candidateDiagnosticSource = executableDiagnosticSource(candidateSource);
const usedExecutableSource: ConnectionTestResponse['usedExecutableSource'] =
executableResolution.configuredOverridePath
? 'fallback_failed'
: candidateDiagnosticSource ?? 'path';
const detail = executableResolution.configuredOverridePath
? agentExecutableFallbackSuccessDetail(
input.agentId,
executableResolution.configuredOverridePath,
candidatePath,
candidateDiagnosticSource,
)
: agentExecutableRetrySuccessDetail(
input.agentId,
fallbackFailedExecutablePath!,
candidatePath,
candidateDiagnosticSource,
);
return {
...fallbackResult,
...(executableResolution.configuredOverridePath
? { configuredExecutablePath: executableResolution.configuredOverridePath }
: {}),
detectedExecutablePath: candidatePath,
...(candidateDiagnosticSource
? { detectedExecutableSource: candidateDiagnosticSource }
: {}),
usedExecutablePath: fallbackResult.usedExecutablePath ?? candidatePath,
usedExecutableSource,
detail: redactSecrets(detail),
};
}
return {
...fallbackResult,
configuredExecutablePath: executableResolution.configuredOverridePath,
detectedExecutablePath: executableResolution.pathResolvedPath,
usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
usedExecutableSource: 'fallback_failed',
...withUsedExecutableDiagnostics(primaryResult),
detail: redactSecrets(
codexExecutableFallbackSuccessDetail(
executableResolution.configuredOverridePath,
executableResolution.pathResolvedPath,
),
[
primaryResult.detail ?? '',
executableFailureDetail,
].filter(Boolean).join(' '),
),
};
}
/**
* Build the ordered list of non-configured candidate paths to retry. We
* start with the preselected fallback candidate (so the simple
* one-alternate layout keeps the same retry target as before), then
* fall through every other non-configured candidate from the detection
* scan in PATH-then-known-install order. Each path is included at most
* once and paths the primary attempt already proved bad are never
* retried. When detection didn't produce any candidates we fall back to
* a live `inspectAgentExecutableCandidates()` walk so deployments
* without a populated detection cache still recover.
*/
function collectFallbackCandidates(
candidates: RuntimeExecutableCandidate[],
excludedPaths: Array<string | null | undefined>,
preselectedPath: string | null,
def: RuntimeAgentDef | null | undefined,
configuredAgentEnv: Record<string, string>,
): string[] {
const excluded = new Set(excludedPaths.filter((value): value is string => Boolean(value)));
const seen = new Set<string>();
const out: string[] = [];
const add = (value: string | null | undefined) => {
if (!value) return;
if (excluded.has(value)) return;
if (seen.has(value)) return;
seen.add(value);
out.push(value);
};
add(preselectedPath);
for (const candidate of candidates) {
if (candidate.source === 'configured') continue;
add(candidate.path);
}
if (out.length === 0 && def) {
for (const candidate of inspectAgentExecutableCandidates(def, configuredAgentEnv)) {
if (candidate.source === 'configured') continue;
add(candidate.path);
}
}
return out;
}

View file

@ -1,7 +1,14 @@
import path from 'node:path';
import { execAgentFile } from './invocation.js';
import { AGENT_DEFS } from './registry.js';
import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js';
import { applyAgentLaunchEnv, resolveAgentLaunch } from './launch.js';
import { AGENT_BIN_ENV_KEYS } from './executables.js';
import {
applyAgentLaunchEnv,
forgetDetectedAgentLaunchSelection,
rememberDetectedAgentLaunchSelection,
resolveAgentLaunch,
} from './launch.js';
import { spawnEnvForAgent } from './env.js';
import { probeAgentAuthStatus } from './auth.js';
import { agentCapabilities } from './capabilities.js';
@ -10,6 +17,7 @@ import type {
DetectedAgent,
RuntimeAgentDef,
RuntimeCapabilityMap,
RuntimeExecutableCandidate,
RuntimeModelSource,
RuntimeModelOption,
} from './types.js';
@ -64,6 +72,11 @@ type VersionProbeOutcome =
| { kind: 'not-invocable' }
| { kind: 'spawned'; version: string | null };
type CandidateProbeLaunch = {
launchPath: string;
childPathPrepend: string[];
};
/**
* Run the agent's `--version` probe and classify the result. The probe
* has two distinct failure modes the catch arm has to discriminate:
@ -117,12 +130,19 @@ async function probeVersionAtPath(
}
}
function unavailableAgent(def: RuntimeAgentDef): DetectedAgent {
function unavailableAgent(
def: RuntimeAgentDef,
overrides: Partial<Pick<
DetectedAgent,
'path' | 'version' | 'executableCandidates'
>> = {},
): DetectedAgent {
return {
...stripFns(def),
models: def.fallbackModels ?? [DEFAULT_MODEL_OPTION],
modelsSource: 'fallback',
available: false,
...overrides,
...installMetaForAgent(def.id),
};
}
@ -139,33 +159,67 @@ async function probe(
// If detection probes the shim but chat/run spawns the native binary, the
// UI incorrectly reports "not installed" until the user pins CODEX_BIN by
// hand even though the real launch path is healthy.
const launch = resolveAgentLaunch(def, configuredEnv);
const launch = resolveAgentLaunch(def, configuredEnv, { useDetectedSelection: false });
if (!launch.selectedPath || !launch.launchPath) {
rememberDetectedAgentLaunchSelection(def, launch, null);
return unavailableAgent(def);
}
const probeEnv = applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
const executableCandidates = await probeExecutableCandidates(
def,
launch.executableCandidates,
configuredEnv,
);
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
const baseProbeEnv = spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
);
let probeEnv = applyAgentLaunchEnv(baseProbeEnv, launch);
let outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
let detectedPath = launch.selectedPath;
let probePath = launch.launchPath;
let candidateList = executableCandidates;
if (outcome.kind === 'not-invocable') {
return unavailableAgent(def);
const promotedCandidate = executableCandidates.find(
(candidate) =>
candidate.source !== 'configured' &&
candidate.path !== launch.selectedPath &&
candidate.available,
);
if (
promotedCandidate &&
!launch.configuredOverridePath &&
launch.selectedPath === launch.pathResolvedPath
) {
const promotedLaunch = resolveCandidateProbeLaunch(def, promotedCandidate, configuredEnv);
detectedPath = promotedCandidate.path;
probePath = promotedLaunch.launchPath;
outcome = { kind: 'spawned', version: promotedCandidate.version ?? null };
probeEnv = applyAgentLaunchEnv(baseProbeEnv, promotedLaunch);
candidateList = executableCandidates.map((candidate) => ({
...candidate,
selected: candidate.path === promotedCandidate.path,
}));
} else {
rememberDetectedAgentLaunchSelection(def, launch, detectedPath);
return unavailableAgent(def, {
path: detectedPath,
version: null,
executableCandidates: candidateList,
});
}
}
// Probe `--help` once per agent and record which flags the installed CLI
// advertises. Cached on `agentCapabilities` for buildArgs to consult.
if (def.helpArgs && def.capabilityFlags) {
const caps: RuntimeCapabilityMap = {};
try {
const { stdout } = await execAgentFile(launch.launchPath, def.helpArgs, {
const { stdout } = await execAgentFile(probePath, def.helpArgs, {
env: probeEnv,
timeout: 5000,
maxBuffer: 4 * 1024 * 1024,
@ -179,15 +233,17 @@ async function probe(
}
agentCapabilities.set(def.id, caps);
}
const modelResult = await fetchModels(def, launch.launchPath, probeEnv);
const auth = await probeAgentAuthStatus(def.id, launch.launchPath, probeEnv);
const modelResult = await fetchModels(def, probePath, probeEnv);
const auth = await probeAgentAuthStatus(def.id, probePath, probeEnv);
rememberDetectedAgentLaunchSelection(def, launch, detectedPath);
return {
...stripFns(def),
models: modelResult.models,
modelsSource: modelResult.source,
available: true,
path: launch.selectedPath,
path: detectedPath,
version: outcome.version,
executableCandidates: candidateList,
...(auth
? {
authStatus: auth.status,
@ -198,6 +254,78 @@ async function probe(
};
}
function resolveCandidateProbeLaunch(
def: RuntimeAgentDef,
candidate: RuntimeExecutableCandidate,
configuredEnv: Record<string, string> = {},
): CandidateProbeLaunch {
const envKey = AGENT_BIN_ENV_KEYS.get(def.id);
// For configured candidates we already received the user-pinned path
// through configuredEnv; reuse it. For other candidates (and for agents
// without an env override key) we synthesize an override so
// `resolveAgentLaunch()` walks the same path the runtime walks when
// launched against this binary.
const candidateEnv: Record<string, string> =
candidate.source === 'configured' || !envKey
? { ...configuredEnv }
: { ...configuredEnv, [envKey]: candidate.path };
const launch = resolveAgentLaunch(def, candidateEnv, { useDetectedSelection: false });
// resolveAgentLaunch() validates the override is a real, invocable file
// before it adopts it. When it refuses (vanished/perm-stripped path), fall
// back to probing the raw candidate so we still record the failure rather
// than silently dropping the entry.
const launchPath =
envKey && candidate.source !== 'configured' && launch.configuredOverridePath !== candidate.path
? candidate.path
: launch.launchPath ?? candidate.path;
return {
launchPath,
childPathPrepend:
launch.launchPath && launch.childPathPrepend.length > 0
? launch.childPathPrepend
: path.isAbsolute(candidate.path)
? [path.dirname(candidate.path)]
: [],
};
}
async function probeExecutableCandidates(
def: RuntimeAgentDef,
candidates: RuntimeExecutableCandidate[] = [],
configuredEnv: Record<string, string> = {},
): Promise<RuntimeExecutableCandidate[]> {
if (candidates.length === 0) return [];
const baseEnv = spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
);
// Probe each candidate through the same launch resolution the runtime
// uses at spawn time. The detection-level probe must not drop below this
// boundary because shared launch logic can substantively rewrite the
// executable that actually runs — most importantly for Codex, where the
// PATH-visible `codex` entry is often a `#!/usr/bin/env node` wrapper
// that fails `--version` from a GUI-launched daemon while
// `resolveAgentLaunch()` upgrades it to a packaged native binary that
// works. Probing the raw candidate path would mark valid Codex
// candidates as broken, hide the `Use` action for paths the real launch
// would handle, and break the new candidate switcher's repair flow.
return Promise.all(
candidates.map(async (candidate) => {
const launch = resolveCandidateProbeLaunch(def, candidate, configuredEnv);
const probeEnv = applyAgentLaunchEnv(baseEnv, launch);
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
if (outcome.kind === 'not-invocable') {
return { ...candidate, available: false, version: null };
}
return { ...candidate, available: true, version: outcome.version };
}),
);
}
function stripFns(
def: RuntimeAgentDef,
): Omit<DetectedAgent, 'models' | 'modelsSource' | 'available' | 'path' | 'version'> {
@ -230,6 +358,7 @@ async function safeProbe(
try {
return await probe(def, configuredEnv);
} catch {
forgetDetectedAgentLaunchSelection(def.id);
// Fault isolation (issue #2297): one adapter's probe blowing up
// — e.g. a synchronous filesystem throw during PATH walking on a
// packaged Windows daemon, or an async rejection from one of the

View file

@ -1,4 +1,4 @@
import { accessSync, constants, existsSync, statSync } from 'node:fs';
import { accessSync, constants, statSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { homedir } from 'node:os';
@ -6,14 +6,18 @@ import { fileURLToPath } from 'node:url';
import { wellKnownUserToolchainBins } from '@open-design/platform';
import { resolveSandboxRuntimeConfigFromEnv } from '../sandbox-mode.js';
import { expandHomePath } from './paths.js';
import type { RuntimeAgentDef } from './types.js';
import type {
RuntimeAgentDef,
RuntimeExecutableCandidate,
RuntimeExecutableCandidateSource,
} from './types.js';
const RUNTIME_PROJECT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../..',
);
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
export const AGENT_BIN_ENV_KEYS = new Map<string, string>([
['amr', 'VELA_BIN'],
['aider', 'AIDER_BIN'],
['claude', 'CLAUDE_BIN'],
@ -73,17 +77,19 @@ function userToolchainDirs() {
return cachedToolchainDirs;
}
function resolvePathDirs() {
function resolvePathDirs(): Array<{ dir: string; source: 'path' | 'known' }> {
const seen = new Set();
const dirs = [
...(process.env.PATH || '').split(delimiter),
...(process.env.PATH || '')
.split(delimiter)
.map((dir) => ({ dir, source: 'path' as const })),
// GUI launchers (macOS .app bundles, Linux .desktop files) often start
// with a minimal PATH. Include common user-level CLI install locations
// so agent detection matches the user's shell-installed tools,
// especially Node version managers.
...userToolchainDirs(),
...userToolchainDirs().map((dir) => ({ dir, source: 'known' as const })),
];
return dirs.filter((dir) => {
return dirs.filter(({ dir }) => {
if (!dir || seen.has(dir)) return false;
seen.add(dir);
return true;
@ -91,18 +97,45 @@ function resolvePathDirs() {
}
export function resolveOnPath(bin: string): string | null {
return findExecutableCandidatesOnPath(bin, 'path')[0]?.path ?? null;
}
function findExecutableCandidatesOnPath(
bin: string,
source: RuntimeExecutableCandidateSource,
): RuntimeExecutableCandidate[] {
const exts =
process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
: [''];
const dirs = resolvePathDirs();
for (const dir of dirs) {
const candidates: RuntimeExecutableCandidate[] = [];
for (const { dir, source: dirSource } of resolvePathDirs()) {
const candidateSource = dirSource === 'known' ? 'known' : source;
for (const ext of exts) {
const full = path.join(dir, bin + ext);
if (full && existsSync(full)) return full;
if (isInvocableFile(full)) {
candidates.push({
path: full,
bin,
source: candidateSource,
available: true,
selected: false,
});
}
}
}
return null;
return candidates;
}
function isInvocableFile(filePath: string): boolean {
try {
if (!statSync(filePath).isFile()) return false;
if (process.platform === 'win32') return looksExecutableOnWindows(filePath);
accessSync(filePath, constants.X_OK);
return true;
} catch {
return false;
}
}
function looksExecutableOnWindows(filePath: string): boolean {
@ -135,7 +168,7 @@ function executableFilePath(raw: string | undefined): string | null {
// Resolve the first available binary for an agent definition. Tries
// `def.bin` first, then walks `def.fallbackBins` in order. Used for
// agents whose forks ship under a different binary name but speak the
// exact same CLI (Claude Code OpenClaude, issue #235). Returns null
// exact same CLI (Claude Code -> OpenClaude, issue #235). Returns null
// when no candidate is on PATH.
function configuredExecutableOverride(
def: RuntimeAgentDef,
@ -146,6 +179,21 @@ function configuredExecutableOverride(
return executableFilePath(configuredEnv?.[envKey]);
}
function configuredExecutableCandidate(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): RuntimeExecutableCandidate | null {
const configuredOverridePath = configuredExecutableOverride(def, configuredEnv);
if (!configuredOverridePath) return null;
return {
path: configuredOverridePath,
bin: path.basename(configuredOverridePath),
source: 'configured',
available: true,
selected: false,
};
}
export function resolveAmrOpenCodeExecutable(
env: Record<string, string | undefined> = process.env,
): string | null {
@ -244,15 +292,19 @@ export function inspectAgentExecutableResolution(
configuredOverridePath: string | null;
pathResolvedPath: string | null;
selectedPath: string | null;
executableCandidates: RuntimeExecutableCandidate[];
} {
if (!def?.bin) {
return {
configuredOverridePath: null,
pathResolvedPath: null,
selectedPath: null,
executableCandidates: [],
};
}
const configuredOverridePath = configuredExecutableOverride(def, configuredEnv);
const executableCandidates = inspectAgentExecutableCandidates(def, configuredEnv);
const configuredOverridePath =
executableCandidates.find((candidate) => candidate.source === 'configured')?.path ?? null;
const candidates = [
def.bin,
...(Array.isArray(def.fallbackBins) ? def.fallbackBins : []),
@ -270,5 +322,35 @@ export function inspectAgentExecutableResolution(
configuredOverridePath,
pathResolvedPath,
selectedPath: configuredOverridePath || builtInPath || pathResolvedPath,
executableCandidates: executableCandidates.map((candidate) => ({
...candidate,
selected: candidate.path === (configuredOverridePath || builtInPath || pathResolvedPath),
})),
};
}
export function inspectAgentExecutableCandidates(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): RuntimeExecutableCandidate[] {
if (!def?.bin) return [];
const seen = new Set<string>();
const out: RuntimeExecutableCandidate[] = [];
const add = (candidate: RuntimeExecutableCandidate | null) => {
if (!candidate || seen.has(candidate.path)) return;
seen.add(candidate.path);
out.push(candidate);
};
add(configuredExecutableCandidate(def, configuredEnv));
for (const bin of [
def.bin,
...(Array.isArray(def.fallbackBins) ? def.fallbackBins : []),
]) {
const source: RuntimeExecutableCandidateSource =
bin === def.bin ? 'path' : 'fallback';
for (const candidate of findExecutableCandidatesOnPath(bin, source)) {
add(candidate);
}
}
return out;
}

View file

@ -12,11 +12,95 @@ export type AgentLaunchResolution = ReturnType<typeof inspectAgentExecutableReso
diagnostic: string | null;
};
type AgentLaunchOptions = {
useDetectedSelection?: boolean;
};
type LaunchResolutionKeyInput = Pick<
ReturnType<typeof inspectAgentExecutableResolution>,
'configuredOverridePath' | 'pathResolvedPath'
>;
type DetectedLaunchSelection = {
key: string;
selectedPath: string;
};
// `detectAgents()` may promote a later healthy candidate after proving the
// primary PATH hit is stale. Cache that promoted choice so the next chat/test
// launch uses the same binary Settings just surfaced.
const detectedLaunchSelections = new Map<string, DetectedLaunchSelection>();
function launchResolutionKey(
def: RuntimeAgentDef,
resolution: LaunchResolutionKeyInput,
): string {
return [
def.id,
resolution.configuredOverridePath ?? '',
resolution.pathResolvedPath ?? '',
].join('\0');
}
export function rememberDetectedAgentLaunchSelection(
def: RuntimeAgentDef,
resolution: Pick<AgentLaunchResolution, 'configuredOverridePath' | 'pathResolvedPath' | 'selectedPath'>,
selectedPath: string | null | undefined,
): void {
if (
!selectedPath ||
selectedPath === resolution.selectedPath ||
resolution.configuredOverridePath
) {
detectedLaunchSelections.delete(def.id);
return;
}
detectedLaunchSelections.set(def.id, {
key: launchResolutionKey(def, resolution),
selectedPath,
});
}
export function forgetDetectedAgentLaunchSelection(agentId: string): void {
detectedLaunchSelections.delete(agentId);
}
function detectedAgentLaunchSelection(
def: RuntimeAgentDef,
resolution: ReturnType<typeof inspectAgentExecutableResolution>,
): string | null {
const cached = detectedLaunchSelections.get(def.id);
if (!cached) return null;
if (cached.key !== launchResolutionKey(def, resolution)) {
detectedLaunchSelections.delete(def.id);
return null;
}
const candidate = resolution.executableCandidates.find(
(item) => item.source !== 'configured' && item.path === cached.selectedPath,
);
if (!candidate) detectedLaunchSelections.delete(def.id);
return candidate?.path ?? null;
}
export function resolveAgentLaunch(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
options: AgentLaunchOptions = {},
): AgentLaunchResolution {
const resolution = inspectAgentExecutableResolution(def, configuredEnv);
const baseResolution = inspectAgentExecutableResolution(def, configuredEnv);
const detectedSelection = options.useDetectedSelection === false
? null
: detectedAgentLaunchSelection(def, baseResolution);
const resolution = detectedSelection
? {
...baseResolution,
selectedPath: detectedSelection,
executableCandidates: baseResolution.executableCandidates.map((candidate) => ({
...candidate,
selected: candidate.path === detectedSelection,
})),
}
: baseResolution;
if (!resolution.selectedPath) {
return { ...resolution, launchPath: null, launchKind: 'selected', childPathPrepend: [], diagnostic: null };
}

View file

@ -59,6 +59,21 @@ export type RuntimeContext = {
export type RuntimeCapabilityMap = Record<string, boolean>;
export type RuntimeExecutableCandidateSource =
| 'configured'
| 'path'
| 'fallback'
| 'known';
export type RuntimeExecutableCandidate = {
path: string;
bin: string;
source: RuntimeExecutableCandidateSource;
available: boolean;
selected: boolean;
version?: string | null;
};
export type RuntimeListModels = {
args: string[];
timeoutMs?: number;
@ -184,6 +199,7 @@ export type DetectedAgent = Omit<
authMessage?: string;
path?: string;
version?: string | null;
executableCandidates?: RuntimeExecutableCandidate[];
};
export type RuntimeExecOptions = ExecFileOptions & {

View file

@ -146,6 +146,9 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
// File written during the run
await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '<p>new</p>');
const newPath = path.join(projectsRoot, PROJECT_ID, 'new-output.html');
const afterRunStart = new Date(runStartTimeMs + 1000);
fs.utimesSync(newPath, afterRunStart, afterRunStart);
// Simulate the close-handler reconciliation with mtime filter
const dir = path.join(projectsRoot, PROJECT_ID);

View file

@ -21,6 +21,8 @@ import {
validateBaseUrlResolved,
type DnsLookupAddress,
} from '../src/connectionTest.js';
import { detectAgents } from '../src/agents.js';
import { forgetDetectedAgentLaunchSelection } from '../src/runtimes/launch.js';
import { listProviderModels } from '../src/providerModels.js';
import { startServer } from '../src/server.js';
@ -2677,6 +2679,543 @@ process.stdin.on('end', () => {
}
});
it('includes OpenCode executable path diagnostics on connection-test failures', async () => {
await withFakeOpenCode(
`
const args = process.argv.slice(2);
if (args[0] === 'models') {
console.log('openai/gpt-5');
process.exit(0);
}
if (args[0] === '--version') {
console.log('opencode 1.1.14');
process.exit(0);
}
console.error('OpenCode 1.1.14 crashed before streaming');
process.exit(1);
`,
async () => {
const result = await testAgentConnection({ agentId: 'opencode' });
expect(result).toMatchObject({
ok: false,
kind: 'agent_spawn_failed',
agentName: 'OpenCode',
usedExecutableSource: 'path',
usedExecutablePath: expect.any(String),
detectedExecutablePath: expect.any(String),
});
expect(result.usedExecutablePath).toContain('opencode');
expect(result.detail).toContain('OpenCode 1.1.14 crashed before streaming');
expect(result.detail).toContain('Used OpenCode binary:');
expect(result.detail).toContain('(opencode 1.1.14)');
},
);
});
it('falls back to PATH OpenCode during connection tests when configured OPENCODE_BIN fails', async () => {
await withFakeOpenCode(
`console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));\n`,
async () => {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-fallback-'));
try {
const bin = path.join(dir, 'opencode-bad');
await fsp.writeFile(
bin,
`#!/usr/bin/env node\nconsole.error('OpenCode old binary crashed');\nprocess.exit(1);\n`,
);
await fsp.chmod(bin, 0o755);
const result = await testAgentConnection({
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: bin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: bin,
detectedExecutablePath: expect.any(String),
usedExecutablePath: expect.any(String),
});
expect(result.detail).toContain(`Configured OpenCode path failed: ${bin}.`);
expect(result.detail).toContain('This test succeeded with the PATH OpenCode CLI at');
expect(result.detail).toContain('Update OPENCODE_BIN or clear the custom path');
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
},
);
});
it('reports known OpenCode candidates as known install-dir fallbacks during connection tests', async () => {
const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-known-home-'));
const configuredDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-configured-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
try {
const configuredBin = path.join(configuredDir, 'opencode-old');
const knownBinDir = path.join(home, '.opencode', 'bin');
const knownBin = path.join(knownBinDir, 'opencode');
await fsp.mkdir(knownBinDir, { recursive: true });
await fsp.writeFile(
configuredBin,
`#!/usr/bin/env node\nconsole.error('OpenCode old binary crashed');\nprocess.exit(1);\n`,
);
await fsp.writeFile(
knownBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.2.0');
process.exit(0);
}
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
`,
);
await fsp.chmod(configuredBin, 0o755);
await fsp.chmod(knownBin, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = '';
const result = await testAgentConnection({
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: configuredBin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: configuredBin,
detectedExecutablePath: knownBin,
detectedExecutableSource: 'known',
usedExecutablePath: knownBin,
});
expect(result.detail).toContain(`Configured OpenCode path failed: ${configuredBin}.`);
expect(result.detail).toContain('This test succeeded with the known OpenCode install at');
expect(result.detail).not.toContain('PATH OpenCode CLI');
expect(result.detail).toContain('Update OPENCODE_BIN or clear the custom path');
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = oldAgentHome;
}
await fsp.rm(home, { recursive: true, force: true });
await fsp.rm(configuredDir, { recursive: true, force: true });
}
});
it('falls back to a later OpenCode candidate when OPENCODE_BIN matches the stale PATH candidate', async () => {
const pathDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-stale-path-'));
const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-later-home-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
try {
const staleBin = path.join(pathDir, 'opencode');
const knownBinDir = path.join(home, '.opencode', 'bin');
const knownBin = path.join(knownBinDir, 'opencode');
await fsp.mkdir(knownBinDir, { recursive: true });
await fsp.writeFile(
staleBin,
`#!/usr/bin/env node\nconsole.error('OpenCode stale PATH binary crashed');\nprocess.exit(1);\n`,
);
await fsp.writeFile(
knownBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.2.0');
process.exit(0);
}
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
`,
);
await fsp.chmod(staleBin, 0o755);
await fsp.chmod(knownBin, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = pathDir;
const result = await testAgentConnection({
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: staleBin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: staleBin,
detectedExecutablePath: knownBin,
detectedExecutableSource: 'known',
usedExecutablePath: knownBin,
});
expect(result.detail).toContain(`Configured OpenCode path failed: ${staleBin}.`);
expect(result.detail).toContain('This test succeeded with the known OpenCode install at');
expect(result.detail).toContain('Update OPENCODE_BIN or clear the custom path');
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = oldAgentHome;
}
await fsp.rm(pathDir, { recursive: true, force: true });
await fsp.rm(home, { recursive: true, force: true });
}
});
it('reports the promoted OpenCode candidate when an invalid configured path falls back to detection', async () => {
const pathDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-promoted-path-'));
const configuredDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-promoted-conf-'));
const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-promoted-home-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
try {
const stalePathBin = path.join(pathDir, 'opencode');
const invalidConfiguredBin = path.join(configuredDir, 'opencode-missing');
const knownBinDir = path.join(home, '.opencode', 'bin');
const knownBin = path.join(knownBinDir, 'opencode');
await fsp.mkdir(knownBinDir, { recursive: true });
await fsp.writeFile(
stalePathBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
process.exit(127);
}
console.error('OpenCode stale PATH binary should not run');
process.exit(1);
`,
);
await fsp.writeFile(
knownBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.2.0');
process.exit(0);
}
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
`,
);
await fsp.chmod(stalePathBin, 0o755);
await fsp.chmod(knownBin, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = pathDir;
const agents = await detectAgents();
const detectedOpenCode = agents.find((agent) => agent.id === 'opencode');
expect(detectedOpenCode).toMatchObject({
available: true,
path: knownBin,
});
const result = await testAgentConnection({
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: invalidConfiguredBin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
usedExecutableSource: 'fallback_invalid',
configuredExecutablePath: invalidConfiguredBin,
detectedExecutablePath: knownBin,
detectedExecutableSource: 'known',
usedExecutablePath: knownBin,
});
expect(result.detail).toContain(
`Configured OpenCode path is invalid or not executable: ${invalidConfiguredBin}.`,
);
expect(result.detail).toContain('This test used the known OpenCode install at');
expect(result.detail).not.toContain(`This test used the PATH OpenCode CLI at ${stalePathBin}.`);
} finally {
forgetDetectedAgentLaunchSelection('opencode');
process.env.PATH = oldPath;
if (oldAgentHome === undefined) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = oldAgentHome;
}
await fsp.rm(pathDir, { recursive: true, force: true });
await fsp.rm(configuredDir, { recursive: true, force: true });
await fsp.rm(home, { recursive: true, force: true });
}
});
it('iterates remaining OpenCode candidates when the first fallback passes --version but crashes during the smoke prompt', async () => {
// Layout: configured OPENCODE_BIN crashes; first PATH candidate is
// invocable (passes --version) but exits during the smoke prompt;
// later known-install candidate succeeds. The recovery flow must
// try the later candidate before giving up — otherwise multi-binary
// machines stay broken even though a working binary is present.
const pathDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-iter-path-'));
const configuredDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-iter-conf-'));
const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-iter-home-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
try {
const configuredBin = path.join(configuredDir, 'opencode-old');
const stalePathBin = path.join(pathDir, 'opencode');
const knownBinDir = path.join(home, '.opencode', 'bin');
const knownBin = path.join(knownBinDir, 'opencode');
await fsp.mkdir(knownBinDir, { recursive: true });
await fsp.writeFile(
configuredBin,
`#!/usr/bin/env node\nconsole.error('OpenCode configured binary crashed');\nprocess.exit(1);\n`,
);
// Stale PATH candidate replies to --version but immediately exits
// when asked to handle the smoke prompt, so a single-retry flow
// would stop here and report failure.
await fsp.writeFile(
stalePathBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.1.14');
process.exit(0);
}
console.error('OpenCode stale PATH binary refused the prompt');
process.exit(1);
`,
);
await fsp.writeFile(
knownBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.2.0');
process.exit(0);
}
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
`,
);
await fsp.chmod(configuredBin, 0o755);
await fsp.chmod(stalePathBin, 0o755);
await fsp.chmod(knownBin, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = pathDir;
const result = await testAgentConnection({
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: configuredBin,
},
},
});
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: configuredBin,
detectedExecutablePath: knownBin,
detectedExecutableSource: 'known',
usedExecutablePath: knownBin,
});
expect(result.detail).toContain(`Configured OpenCode path failed: ${configuredBin}.`);
expect(result.detail).toContain('This test succeeded with the known OpenCode install at');
expect(result.detail).toContain('Update OPENCODE_BIN or clear the custom path');
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = oldAgentHome;
}
await fsp.rm(pathDir, { recursive: true, force: true });
await fsp.rm(configuredDir, { recursive: true, force: true });
await fsp.rm(home, { recursive: true, force: true });
}
});
it('tries a known OpenCode install after a stale PATH candidate without OPENCODE_BIN', async () => {
const pathDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-direct-path-'));
const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-direct-home-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
try {
forgetDetectedAgentLaunchSelection('opencode');
const stalePathBin = path.join(pathDir, 'opencode');
const knownBinDir = path.join(home, '.opencode', 'bin');
const knownBin = path.join(knownBinDir, 'opencode');
await fsp.mkdir(knownBinDir, { recursive: true });
await fsp.writeFile(
stalePathBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.1.14');
process.exit(0);
}
console.error('OpenCode stale PATH binary refused the prompt');
process.exit(1);
`,
);
await fsp.writeFile(
knownBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.2.0');
process.exit(0);
}
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
`,
);
await fsp.chmod(stalePathBin, 0o755);
await fsp.chmod(knownBin, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = pathDir;
const result = await testAgentConnection({ agentId: 'opencode' });
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
detectedExecutablePath: knownBin,
detectedExecutableSource: 'known',
usedExecutablePath: knownBin,
usedExecutableSource: 'known',
});
expect(result).not.toHaveProperty('configuredExecutablePath');
expect(result.detail).toContain(`OpenCode binary failed: ${stalePathBin}.`);
expect(result.detail).toContain('This test succeeded with the known OpenCode install at');
} finally {
forgetDetectedAgentLaunchSelection('opencode');
process.env.PATH = oldPath;
if (oldAgentHome === undefined) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = oldAgentHome;
}
await fsp.rm(pathDir, { recursive: true, force: true });
await fsp.rm(home, { recursive: true, force: true });
}
});
it('keeps OpenCode fallback retries within one connection-test timeout when a fallback hangs', async () => {
const pathDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-hang-path-'));
const configuredDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-hang-conf-'));
const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-opencode-hang-home-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
const oldTimeout = process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS;
try {
const configuredBin = path.join(configuredDir, 'opencode-old');
const stalePathBin = path.join(pathDir, 'opencode');
const knownBinDir = path.join(home, '.opencode', 'bin');
const knownBin = path.join(knownBinDir, 'opencode');
await fsp.mkdir(knownBinDir, { recursive: true });
await fsp.writeFile(
configuredBin,
`#!/usr/bin/env node\nconsole.error('OpenCode configured binary crashed');\nprocess.exit(1);\n`,
);
await fsp.writeFile(
stalePathBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.1.14');
process.exit(0);
}
setTimeout(() => {}, 10_000);
`,
);
await fsp.writeFile(
knownBin,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('opencode 1.2.0');
process.exit(0);
}
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
`,
);
await fsp.chmod(configuredBin, 0o755);
await fsp.chmod(stalePathBin, 0o755);
await fsp.chmod(knownBin, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS = '2000';
process.env.PATH = pathDir;
const start = Date.now();
const result = await testAgentConnection({
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: configuredBin,
},
},
});
const elapsedMs = Date.now() - start;
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'OpenCode',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: configuredBin,
detectedExecutablePath: knownBin,
detectedExecutableSource: 'known',
usedExecutablePath: knownBin,
});
expect(elapsedMs).toBeLessThan(1_900);
expect(result.detail).toContain(`Configured OpenCode path failed: ${configuredBin}.`);
expect(result.detail).toContain('This test succeeded with the known OpenCode install at');
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = oldAgentHome;
}
if (oldTimeout === undefined) {
delete process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS;
} else {
process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS = oldTimeout;
}
await fsp.rm(pathDir, { recursive: true, force: true });
await fsp.rm(configuredDir, { recursive: true, force: true });
await fsp.rm(home, { recursive: true, force: true });
}
});
it('reports Cursor Agent status auth failures before running the smoke prompt', async () => {
await withFakeCursorAgent(
`

View file

@ -378,6 +378,22 @@ test('inspectAgentExecutableResolution reports configured and PATH Codex binarie
configuredOverridePath: configured,
pathResolvedPath: fallback,
selectedPath: configured,
executableCandidates: [
{
path: configured,
bin: 'codex-custom',
source: 'configured',
available: true,
selected: true,
},
{
path: fallback,
bin: 'codex',
source: 'path',
available: true,
selected: false,
},
],
});
});
} finally {

View file

@ -1,10 +1,21 @@
import { realpathSync, symlinkSync } from 'node:fs';
import { test } from 'vitest';
import {
AGENT_DEFS, assert, chmodSync, codex, cursorAgent, detectAgents, join, mkdtempSync, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
AGENT_DEFS, assert, chmodSync, codex, cursorAgent, detectAgents, join, mkdirSync, mkdtempSync, opencode, resolveAgentLaunch, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
import { codexNeedsDangerFullAccessSandbox } from '../../src/runtimes/defs/codex.js';
import { readLocalAgentProfileDefs } from '../../src/runtimes/registry.js';
function codexNativeTargetTriple(): string {
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
return `${process.platform}-${process.arch}`;
}
test('AGENT_DEFS ids are unique', () => {
const ids = AGENT_DEFS.map((a) => a.id);
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
@ -380,6 +391,283 @@ exit 2
}
});
test('opencode detection surfaces every executable candidate in PATH order plus known install dirs', async () => {
const pathDir = mkdtempSync(join(tmpdir(), 'od-agents-opencode-path-'));
const home = mkdtempSync(join(tmpdir(), 'od-agents-opencode-home-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const oldOpenCode = join(pathDir, 'opencode');
const homeBinDir = join(home, '.opencode', 'bin');
mkdirSync(homeBinDir, { recursive: true });
const newOpenCode = join(homeBinDir, 'opencode');
writeFileSync(
oldOpenCode,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "opencode 1.1.14"; exit 0; fi\nexit 0\n',
);
writeFileSync(
newOpenCode,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "opencode 1.2.0"; exit 0; fi\nexit 0\n',
);
chmodSync(oldOpenCode, 0o755);
chmodSync(newOpenCode, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = pathDir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'opencode');
assert.ok(detected);
assert.equal(detected.available, true);
assert.equal(detected.path, oldOpenCode);
assert.deepEqual(detected.executableCandidates?.map((candidate) => ({
path: candidate.path,
version: candidate.version,
selected: candidate.selected,
})), [
{ path: oldOpenCode, version: 'opencode 1.1.14', selected: true },
{ path: newOpenCode, version: 'opencode 1.2.0', selected: false },
]);
});
} finally {
rmSync(pathDir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
});
test('opencode detection promotes a healthy known candidate when the PATH candidate is stale', async () => {
const pathDir = mkdtempSync(join(tmpdir(), 'od-agents-opencode-stale-path-'));
const home = mkdtempSync(join(tmpdir(), 'od-agents-opencode-healthy-home-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const staleOpenCode = join(pathDir, 'opencode');
const homeBinDir = join(home, '.opencode', 'bin');
mkdirSync(homeBinDir, { recursive: true });
const healthyOpenCode = join(homeBinDir, 'opencode');
writeFileSync(
staleOpenCode,
'#!/bin/sh\nif [ "$1" = "--version" ]; then exit 127; fi\nexit 127\n',
);
writeFileSync(
healthyOpenCode,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "opencode 1.2.0"; exit 0; fi\nexit 0\n',
);
chmodSync(staleOpenCode, 0o755);
chmodSync(healthyOpenCode, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = pathDir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'opencode');
assert.ok(detected);
assert.equal(detected.available, true);
assert.equal(detected.path, healthyOpenCode);
assert.equal(detected.version, 'opencode 1.2.0');
assert.deepEqual(detected.executableCandidates?.map((candidate) => ({
path: candidate.path,
available: candidate.available,
version: candidate.version,
selected: candidate.selected,
})), [
{ path: staleOpenCode, available: false, version: null, selected: false },
{ path: healthyOpenCode, available: true, version: 'opencode 1.2.0', selected: true },
]);
const launch = resolveAgentLaunch(opencode);
assert.equal(launch.selectedPath, healthyOpenCode);
assert.equal(launch.launchPath, healthyOpenCode);
});
} finally {
rmSync(pathDir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
});
test('opencode detection preserves candidate diagnostics when a configured binary is stale', async () => {
const configuredDir = mkdtempSync(join(tmpdir(), 'od-agents-opencode-stale-configured-'));
const home = mkdtempSync(join(tmpdir(), 'od-agents-opencode-healthy-home-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const staleOpenCode = join(configuredDir, 'opencode-old');
const homeBinDir = join(home, '.opencode', 'bin');
mkdirSync(homeBinDir, { recursive: true });
const healthyOpenCode = join(homeBinDir, 'opencode');
writeFileSync(
staleOpenCode,
'#!/bin/sh\nif [ "$1" = "--version" ]; then exit 127; fi\nexit 127\n',
);
writeFileSync(
healthyOpenCode,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "opencode 1.2.0"; exit 0; fi\nexit 0\n',
);
chmodSync(staleOpenCode, 0o755);
chmodSync(healthyOpenCode, 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = '';
const agents = await detectAgents({ opencode: { OPENCODE_BIN: staleOpenCode } });
const detected = agents.find((agent) => agent.id === 'opencode');
assert.ok(detected);
assert.equal(detected.available, false);
assert.equal(detected.path, staleOpenCode);
assert.deepEqual(detected.executableCandidates?.map((candidate) => ({
path: candidate.path,
available: candidate.available,
version: candidate.version,
selected: candidate.selected,
})), [
{ path: staleOpenCode, available: false, version: null, selected: true },
{ path: healthyOpenCode, available: true, version: 'opencode 1.2.0', selected: false },
]);
});
} finally {
rmSync(configuredDir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
});
// Probing candidate paths directly with `--version` is incorrect for Codex
// because the PATH-visible `codex` entry is often a `#!/usr/bin/env node`
// wrapper that fails to invoke from a stripped GUI environment, while
// `resolveAgentLaunch()` upgrades it to the packaged native binary that
// the runtime actually spawns. `probeExecutableCandidates()` must route
// every candidate through the same shared launch resolver so Settings
// surfaces a Codex wrapper candidate as healthy whenever the real launch
// path works — otherwise the `Use` action gets hidden for paths the
// runtime would happily spawn.
test('codex detection probes wrapper candidates through launch resolution', async () => {
const fsTest = process.platform === 'win32' ? false : true;
if (!fsTest) return;
const root = mkdtempSync(join(tmpdir(), 'od-agents-codex-wrapper-candidates-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const wrapperPkgDir = join(root, 'node_modules', '@openai', 'codex');
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
const wrapperLinkDir = join(root, 'node_modules', '.bin');
const wrapperLinkPath = join(wrapperLinkDir, 'codex');
const nativePkgDir = join(wrapperPkgDir, 'node_modules', '@openai', `codex-${process.platform}-${process.arch}`);
const nativePathDir = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'path');
const nativeBin = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex', 'codex');
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
mkdirSync(wrapperLinkDir, { recursive: true });
mkdirSync(join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex'), { recursive: true });
mkdirSync(nativePathDir, { recursive: true });
// Wrapper that resembles the real @openai/codex shim but cannot be
// invoked from this test (the body satisfies looksLikeCodexNodeWrapper
// so the resolver will keep searching). When probed directly the
// wrapper exits with code 127, matching the "shim references a target
// that is not on PATH" failure mode that motivated this PR.
writeFileSync(
wrapperRealPath,
'#!/bin/sh\n# @openai/codex wrapper\nexit 127\n',
);
// Native binary the launch resolver upgrades to. Reports a real
// version string so the candidate row carries useful diagnostics.
writeFileSync(
nativeBin,
`#!/bin/sh
if [ "$1" = "--version" ]; then echo "codex 1.0.0"; exit 0; fi
exit 0
`,
);
chmodSync(wrapperRealPath, 0o755);
chmodSync(nativeBin, 0o755);
symlinkSync(wrapperRealPath, wrapperLinkPath);
process.env.PATH = wrapperLinkDir;
process.env.OD_AGENT_HOME = root;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'codex');
assert.ok(detected);
assert.equal(detected.available, true);
const wrapperCandidate = detected.executableCandidates?.find(
(candidate) => candidate.path === wrapperLinkPath,
);
assert.ok(
wrapperCandidate,
'codex wrapper must appear as an executable candidate',
);
// Without the launch-resolution probe this assertion fails: raw
// `--version` against the wrapper exits 127 and the candidate is
// recorded as `available: false`.
assert.equal(wrapperCandidate.available, true);
assert.equal(wrapperCandidate.version, 'codex 1.0.0');
// realpathSync because the wrapper was reached through a symlink
// and detection canonicalises the native binary path.
assert.equal(realpathSync(nativeBin), realpathSync(nativeBin));
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('codex detection promotes a resolved wrapper candidate without losing live model probes', async () => {
const fsTest = process.platform === 'win32' ? false : true;
if (!fsTest) return;
const stalePathDir = mkdtempSync(join(tmpdir(), 'od-agents-codex-stale-path-'));
const home = mkdtempSync(join(tmpdir(), 'od-agents-codex-promoted-wrapper-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const staleCodex = join(stalePathDir, 'codex');
const wrapperPkgDir = join(home, '.local', 'lib', 'node_modules', '@openai', 'codex');
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
const wrapperLinkDir = join(home, '.local', 'bin');
const wrapperLinkPath = join(wrapperLinkDir, 'codex');
const nativePkgDir = join(wrapperPkgDir, 'node_modules', '@openai', `codex-${process.platform}-${process.arch}`);
const nativePathDir = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'path');
const nativeBin = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex', 'codex');
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
mkdirSync(wrapperLinkDir, { recursive: true });
mkdirSync(join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex'), { recursive: true });
mkdirSync(nativePathDir, { recursive: true });
writeFileSync(
staleCodex,
'#!/bin/sh\nif [ "$1" = "--version" ]; then exit 127; fi\nexit 127\n',
);
writeFileSync(
wrapperRealPath,
'#!/bin/sh\n# @openai/codex wrapper\nexit 127\n',
);
writeFileSync(
nativeBin,
`#!/bin/sh
if [ "$1" = "--version" ]; then echo "codex 2.0.0"; exit 0; fi
if [ "$1" = "debug" ] && [ "$2" = "models" ]; then
printf '%s\\n' '{"models":[{"slug":"gpt-live-promoted","display_name":"GPT Live Promoted"}]}'
exit 0
fi
exit 0
`,
);
chmodSync(staleCodex, 0o755);
chmodSync(wrapperRealPath, 0o755);
chmodSync(nativeBin, 0o755);
symlinkSync(wrapperRealPath, wrapperLinkPath);
process.env.PATH = stalePathDir;
process.env.OD_AGENT_HOME = home;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'codex');
assert.ok(detected);
assert.equal(detected.available, true);
assert.equal(detected.path, wrapperLinkPath);
assert.equal(detected.version, 'codex 2.0.0');
assert.equal(detected.modelsSource, 'live');
assert.ok(detected.models.some((model) => model.id === 'gpt-live-promoted'));
const launch = resolveAgentLaunch(codex);
assert.equal(launch.selectedPath, wrapperLinkPath);
assert.equal(realpathSync(launch.launchPath ?? ''), realpathSync(nativeBin));
});
} finally {
rmSync(stalePathDir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
});
test('codex picker includes gpt-5.1 model family', () => {
const pickerModels = new Set(codex.fallbackModels.map((model) => model.id));

View file

@ -216,44 +216,108 @@ export interface AgentRefreshOptions {
agentCliEnv?: AppConfig['agentCliEnv'];
}
function codexPathStrings(locale: Locale) {
type CliExecutableSource = 'path' | 'fallback' | 'known' | 'configured';
function cliExecutableSourceLabel(
locale: Locale,
agentName: string,
source: CliExecutableSource | undefined,
): string {
if (locale === 'zh-CN') {
switch (source) {
case 'known':
return `已知安装位置中的 ${agentName}`;
case 'configured':
return `已配置的 ${agentName} 路径`;
case 'fallback':
case 'path':
default:
return `PATH 中的 ${agentName} CLI`;
}
}
if (locale === 'zh-TW') {
switch (source) {
case 'known':
return `已知安裝位置中的 ${agentName}`;
case 'configured':
return `已設定的 ${agentName} 路徑`;
case 'fallback':
case 'path':
default:
return `PATH 中的 ${agentName} CLI`;
}
}
switch (source) {
case 'known':
return `the known ${agentName} install`;
case 'configured':
return `the configured ${agentName} path`;
case 'fallback':
case 'path':
default:
return `the PATH ${agentName} CLI`;
}
}
function cliPathStrings(locale: Locale, agentName = 'Codex', envKey = 'CODEX_BIN') {
if (locale === 'zh-CN') {
return {
repairHint: '当前保存的 Codex 路径不适合继续使用。',
useDetected: '使用检测到的 Codex',
repairHint: `当前保存的 ${agentName} 路径不适合继续使用。`,
useDetected: `使用检测到的 ${agentName}`,
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 或清空自定义路径。`,
configuredSuccess: (path: string) => `本次测试使用的是已配置的 ${agentName} 路径:${path}`,
invalidFallback: (configuredPath: string, detectedPath: string, source?: CliExecutableSource) =>
`已配置的 ${agentName} 路径无效或不可执行:${configuredPath}。本次测试改用 ${cliExecutableSourceLabel(locale, agentName, source)}${detectedPath}。建议更新 ${envKey} 或清空自定义路径。`,
failedFallback: (configuredPath: string, detectedPath: string, source?: CliExecutableSource) =>
`已配置的 ${agentName} 路径启动失败:${configuredPath}。本次测试改用 ${cliExecutableSourceLabel(locale, agentName, source)}${detectedPath}。建议更新 ${envKey} 或清空自定义路径。`,
candidatesTitle: `检测到的 ${agentName} binaries`,
current: '当前',
use: '使用',
useBinary: (path: string) => `使用 ${agentName} binary ${path}`,
tryNext: '试下一个 candidate',
};
}
if (locale === 'zh-TW') {
return {
repairHint: '目前儲存的 Codex 路徑不適合繼續使用。',
useDetected: '使用偵測到的 Codex',
repairHint: `目前儲存的 ${agentName} 路徑不適合繼續使用。`,
useDetected: `使用偵測到的 ${agentName}`,
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 或清除自訂路徑。`,
configuredSuccess: (path: string) => `本次測試使用的是已設定的 ${agentName} 路徑:${path}`,
invalidFallback: (configuredPath: string, detectedPath: string, source?: CliExecutableSource) =>
`已設定的 ${agentName} 路徑無效或不可執行:${configuredPath}。本次測試改用 ${cliExecutableSourceLabel(locale, agentName, source)}${detectedPath}。建議更新 ${envKey} 或清除自訂路徑。`,
failedFallback: (configuredPath: string, detectedPath: string, source?: CliExecutableSource) =>
`已設定的 ${agentName} 路徑啟動失敗:${configuredPath}。本次測試改用 ${cliExecutableSourceLabel(locale, agentName, source)}${detectedPath}。建議更新 ${envKey} 或清除自訂路徑。`,
candidatesTitle: `偵測到的 ${agentName} binaries`,
current: '目前',
use: '使用',
useBinary: (path: string) => `使用 ${agentName} binary ${path}`,
tryNext: '試下個 candidate',
};
}
return {
repairHint: 'The saved Codex path is not the binary this test should keep using.',
useDetected: 'Use detected Codex',
repairHint: `The saved ${agentName} path is not the binary this test should keep using.`,
useDetected: `Use detected ${agentName}`,
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.`,
`This test used the configured ${agentName} path: ${path}.`,
invalidFallback: (configuredPath: string, detectedPath: string, source?: CliExecutableSource) =>
`Configured ${agentName} path is invalid or not executable: ${configuredPath}. This test used ${cliExecutableSourceLabel(locale, agentName, source)} at ${detectedPath}. Update ${envKey} or clear the custom path to use the detected binary.`,
failedFallback: (configuredPath: string, detectedPath: string, source?: CliExecutableSource) =>
`Configured ${agentName} path failed: ${configuredPath}. This test succeeded with ${cliExecutableSourceLabel(locale, agentName, source)} at ${detectedPath}. Update ${envKey} or clear the custom path to use the detected binary.`,
candidatesTitle: `Detected ${agentName} binaries`,
current: 'Current',
use: 'Use',
useBinary: (path: string) => `Use ${agentName} binary ${path}`,
tryNext: 'Try next candidate',
};
}
function cliBinaryEnvKey(agentId: string): string | null {
if (agentId === 'codex') return 'CODEX_BIN';
if (agentId === 'opencode') return 'OPENCODE_BIN';
return null;
}
function sanitizeHttpsUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
@ -457,7 +521,14 @@ function cleanAgentVersionLabel(
}
function displayAgentName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'Open Design AMR' : agent.name;
if (agent.id === 'amr') return 'Open Design AMR';
if (agent.id === 'codex') return 'Codex';
return agent.name;
}
function agentCardDisplayName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
if (agent.id === 'amr') return 'Open Design AMR';
return agent.name;
}
export function mergeProviderModelOptions(
@ -531,6 +602,12 @@ const AGENT_CLI_ENV_FIELDS = [
placeholder: 'Paste OPENAI_API_KEY',
secret: true,
},
{
agentId: 'opencode',
envKey: 'OPENCODE_BIN',
labelKey: 'settings.cliEnvOpenCodeBin',
placeholder: '/absolute/path/to/opencode',
},
] as const;
function defaultApiProtocolConfig(protocol: ApiProtocol): ApiProtocolConfig {
@ -702,7 +779,7 @@ function apiModelOptionLabel(model: ProviderModelOption): string {
: model.id;
}
function codexPathRepairState(
function cliPathRepairState(
result: ConnectionTestResponse,
): { detectedPath: string; canUseDetected: boolean } | null {
if (!result.ok) return null;
@ -720,6 +797,51 @@ function codexPathRepairState(
};
}
const EXECUTABLE_CANDIDATE_RETRY_KINDS = new Set<ConnectionTestResponse['kind']>([
'agent_not_installed',
'agent_spawn_failed',
'unknown',
]);
function canTryNextExecutableCandidate(result: ConnectionTestResponse): boolean {
return !result.ok && EXECUTABLE_CANDIDATE_RETRY_KINDS.has(result.kind);
}
function nextExecutableCandidatePath(
agent: AgentInfo,
result: ConnectionTestResponse,
): string | null {
if (!canTryNextExecutableCandidate(result)) return null;
const candidates = agent.executableCandidates?.filter((candidate) => candidate.path) ?? [];
const availableCandidates = candidates.filter(
(candidate) => candidate.available && candidate.path,
);
if (availableCandidates.length === 0) return null;
const reportedPaths = [
result.usedExecutablePath?.trim(),
result.detectedExecutablePath?.trim(),
].filter((path): path is string => Boolean(path));
const reportedCandidatePath =
reportedPaths.find((path) => candidates.some((candidate) => candidate.path === path)) ?? '';
const selectedCandidatePath =
candidates.find((candidate) => candidate.selected && candidate.path)?.path ??
agent.path?.trim() ??
'';
const cursorPath = reportedCandidatePath || selectedCandidatePath;
if (!availableCandidates.some((candidate) => candidate.path !== cursorPath)) return null;
const usedIndex = availableCandidates.findIndex((candidate) => candidate.path === cursorPath);
const start = usedIndex >= 0 ? usedIndex + 1 : 0;
for (let offset = 0; offset < availableCandidates.length; offset += 1) {
const candidate = availableCandidates[(start + offset) % availableCandidates.length];
if (candidate && candidate.path !== cursorPath) return candidate.path;
}
return null;
}
function selectedCliAgent(agents: AgentInfo[], agentId: string | null | undefined): AgentInfo | null {
return agents.find((agent) => agent.id === agentId) ?? null;
}
/**
* Returns whether the modal's footer Save button should be enabled for the
* currently active sidebar section.
@ -1516,22 +1638,28 @@ export function SettingsDialog({
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 (kindForSuccess === 'cli' && cfg.agentId && cliBinaryEnvKey(cfg.agentId)) {
const selectedAgent = agents.find((agent) => agent.id === cfg.agentId);
const cliStrings = cliPathStrings(
locale,
selectedAgent ? displayAgentName(selectedAgent) : agentName,
cliBinaryEnvKey(cfg.agentId) ?? undefined,
);
if (
result.usedExecutableSource === 'configured' &&
result.configuredExecutablePath
) {
return `${baseMessage} ${codexStrings.configuredSuccess(result.configuredExecutablePath)}`;
return `${baseMessage} ${cliStrings.configuredSuccess(result.configuredExecutablePath)}`;
}
if (
result.usedExecutableSource === 'fallback_invalid' &&
result.configuredExecutablePath &&
result.detectedExecutablePath
) {
return `${baseMessage} ${codexStrings.invalidFallback(
return `${baseMessage} ${cliStrings.invalidFallback(
result.configuredExecutablePath,
result.detectedExecutablePath,
result.detectedExecutableSource,
)}`;
}
if (
@ -1539,9 +1667,10 @@ export function SettingsDialog({
result.configuredExecutablePath &&
result.detectedExecutablePath
) {
return `${baseMessage} ${codexStrings.failedFallback(
return `${baseMessage} ${cliStrings.failedFallback(
result.configuredExecutablePath,
result.detectedExecutablePath,
result.detectedExecutableSource,
)}`;
}
}
@ -1578,13 +1707,20 @@ export function SettingsDialog({
}
};
const applyCodexDetectedPath = (detectedPath: string) => {
setCfg((c) => updateAgentCliEnvValue(c, 'codex', 'CODEX_BIN', detectedPath));
const applyDetectedCliPath = (
agentId: string,
detectedPath: string,
) => {
const envKey = cliBinaryEnvKey(agentId);
if (!envKey) return;
setCfg((c) => updateAgentCliEnvValue(c, agentId, envKey, detectedPath));
setAgentTestState({ status: 'idle' });
};
const clearCodexCustomPath = () => {
setCfg((c) => updateAgentCliEnvValue(c, 'codex', 'CODEX_BIN', ''));
const clearCliCustomPath = (agentId: string) => {
const envKey = cliBinaryEnvKey(agentId);
if (!envKey) return;
setCfg((c) => updateAgentCliEnvValue(c, agentId, envKey, ''));
setAgentTestState({ status: 'idle' });
};
@ -2675,7 +2811,7 @@ export function SettingsDialog({
active && agentTestState.status === 'running';
const isAmrAgent = a.id === 'amr';
const description = AGENT_SHORT_DESCRIPTIONS[a.id];
const agentName = displayAgentName(a);
const agentName = agentCardDisplayName(a);
const modelSummary = agentModelSummary(a);
const amrBenefits = [
t('settings.amrBenefitOfficial'),
@ -2930,38 +3066,69 @@ export function SettingsDialog({
</div>
</div>
) : null}
{cfg.agentId === 'codex' && (() => {
const repair = codexPathRepairState(
{(() => {
const envKey = cliBinaryEnvKey(a.id);
if (!envKey) return null;
const repair = cliPathRepairState(
agentTestState.result,
);
if (!repair) return null;
const codexStrings = codexPathStrings(locale);
const nextPath = nextExecutableCandidatePath(
a,
agentTestState.result,
);
if (!repair && !nextPath) return null;
const cliStrings = cliPathStrings(
locale,
displayAgentName(a),
envKey,
);
return (
<div className="settings-test-actions">
<span className="settings-test-actions-hint">
{codexStrings.repairHint}
</span>
{repair ? (
<span className="settings-test-actions-hint">
{cliStrings.repairHint}
</span>
) : null}
<div className="settings-test-actions-row">
{repair.canUseDetected ? (
{repair?.canUseDetected ? (
<button
type="button"
className="settings-test-btn"
onClick={() =>
applyCodexDetectedPath(
applyDetectedCliPath(
a.id,
repair.detectedPath,
)
}
>
{codexStrings.useDetected}
{cliStrings.useDetected}
</button>
) : null}
{repair ? (
<button
type="button"
className="ghost icon-btn settings-rescan-btn"
onClick={() =>
clearCliCustomPath(a.id)
}
>
{cliStrings.clearCustom}
</button>
) : null}
{nextPath ? (
<button
type="button"
className="settings-test-btn"
onClick={() =>
applyDetectedCliPath(
a.id,
nextPath,
)
}
>
{cliStrings.tryNext}
</button>
) : null}
<button
type="button"
className="ghost icon-btn settings-rescan-btn"
onClick={clearCodexCustomPath}
>
{codexStrings.clearCustom}
</button>
</div>
</div>
);
@ -2981,6 +3148,91 @@ export function SettingsDialog({
</div>
)}
</div>
{(() => {
const selected = selectedCliAgent(agents, cfg.agentId);
if (!selected) return null;
const envKey = cliBinaryEnvKey(selected.id);
if (!envKey) return null;
const candidates =
selected.executableCandidates?.filter(
(candidate) => candidate.path,
) ?? [];
if (candidates.length < 2) return null;
const configuredPath =
cfg.agentCliEnv?.[selected.id]?.[envKey]?.trim() ?? '';
const currentPath =
configuredPath ||
candidates.find((candidate) => candidate.selected)
?.path ||
selected.path ||
'';
const agentName = displayAgentName(selected);
const cliStrings = cliPathStrings(locale, agentName, envKey);
return (
<div className="agent-candidates">
<div className="agent-candidates-head">
<span className="agent-candidates-title">
{cliStrings.candidatesTitle}
</span>
</div>
<div className="agent-candidate-list">
{candidates.map((candidate) => {
const isCurrent =
candidate.path === currentPath ||
(!currentPath && candidate.selected);
return (
<div
key={`${candidate.source}:${candidate.path}`}
className="agent-candidate-row"
>
<div className="agent-candidate-main">
<span
className="agent-candidate-path"
title={candidate.path}
>
{candidate.path}
</span>
<span className="agent-candidate-meta">
{candidate.version ||
t('common.installed')}
{' · '}
{cliExecutableSourceLabel(
locale,
agentName,
candidate.source,
)}
</span>
</div>
<div className="agent-candidate-actions">
{isCurrent ? (
<span className="agent-candidate-badge">
{cliStrings.current}
</span>
) : candidate.available ? (
<button
type="button"
className="ghost icon-btn settings-rescan-btn"
aria-label={cliStrings.useBinary(
candidate.path,
)}
onClick={() =>
applyDetectedCliPath(
selected.id,
candidate.path,
)
}
>
{cliStrings.use}
</button>
) : null}
</div>
</div>
);
})}
</div>
</div>
);
})()}
{unavailableAgents.length > 0 ? (
<details
className="agent-install-collapse"
@ -2999,7 +3251,7 @@ export function SettingsDialog({
const docsUrl = sanitizeHttpsUrl(a.docsUrl);
const hasLinks = Boolean(installUrl || docsUrl);
const description = AGENT_SHORT_DESCRIPTIONS[a.id];
const agentName = displayAgentName(a);
const agentName = agentCardDisplayName(a);
const cardLabel = `${agentName} · ${t('common.notInstalled')}`;
return (
<div

View file

@ -332,6 +332,7 @@ export const ar: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'مخصص (اكتب أدناه)...',
'settings.modelCustomLabel': 'معرف النموذج المخصص',
'settings.modelCustomPlaceholder': 'مثلاً: anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const de: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Benutzerdefiniert (unten eingeben)…',
'settings.modelCustomLabel': 'Benutzerdefinierte Modell-ID',
'settings.modelCustomPlaceholder': 'z. B. anthropic/claude-sonnet-4-6',

View file

@ -343,6 +343,7 @@ export const en: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Custom (type below)…',
'settings.modelCustomLabel': 'Custom model id',
'settings.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const esES: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Personalizado (escribe abajo)…',
'settings.modelCustomLabel': 'Id de modelo personalizado',
'settings.modelCustomPlaceholder': 'p. ej., anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const fa: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'سفارشی (در زیر تایپ کنید)…',
'settings.modelCustomLabel': 'شناسه مدل سفارشی',
'settings.modelCustomPlaceholder': 'مثلاً anthropic/claude-sonnet-4-6',

View file

@ -324,6 +324,7 @@ export const fr: Dict = {
'settings.cliEnvCodexBin': 'Chemin de lexécutable Codex',
'settings.cliEnvCodexBaseUrl': 'URL de base du proxy Codex/OpenAI',
'settings.cliEnvCodexApiKey': 'Clé API du proxy Codex/OpenAI',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Personnalisé (saisir ci-dessous)…',
'settings.modelCustomLabel': 'Identifiant du modèle personnalisé',
'settings.modelCustomPlaceholder': 'ex. anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const hu: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Egyedi (gépeld be alább)…',
'settings.modelCustomLabel': 'Egyedi modell-id',
'settings.modelCustomPlaceholder': 'pl. anthropic/claude-sonnet-4-6',

View file

@ -328,6 +328,7 @@ export const id: Dict = {
'settings.cliEnvCodexBin': 'Path executable Codex',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Kustom (isi di bawah)...',
'settings.modelCustomLabel': 'ID model kustom',
'settings.modelCustomPlaceholder': 'mis. anthropic/claude-sonnet-4-6',

View file

@ -324,6 +324,7 @@ export const it: Dict = {
'settings.cliEnvClaudeConfigDir': 'Directory di configurazione Claude Code',
'settings.cliEnvCodexHome': 'Home di Codex',
'settings.cliEnvCodexBin': 'Percorso eseguibile Codex',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Personalizzato (inserisci sotto)…',
'settings.modelCustomLabel': 'Identificatore del modello personalizzato',
'settings.modelCustomPlaceholder': 'es. anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const ja: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'カスタム(下に入力)…',
'settings.modelCustomLabel': 'カスタムモデル ID',
'settings.modelCustomPlaceholder': '例: anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const ko: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': '직접 입력…',
'settings.modelCustomLabel': '사용자 지정 모델 ID',
'settings.modelCustomPlaceholder': '예: anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const pl: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Własny (wpisz poniżej)…',
'settings.modelCustomLabel': 'Własne ID modelu',
'settings.modelCustomPlaceholder': 'np. anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const ptBR: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Personalizado (digite abaixo)…',
'settings.modelCustomLabel': 'Id do modelo personalizado',
'settings.modelCustomPlaceholder': 'ex.: anthropic/claude-sonnet-4-6',

View file

@ -332,6 +332,7 @@ export const ru: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Пользовательская (введите ниже)…',
'settings.modelCustomLabel': 'Пользовательский ID модели',
'settings.modelCustomPlaceholder': 'например, anthropic/claude-sonnet-4-6',

View file

@ -321,6 +321,7 @@ export const th: Dict = {
'settings.cliEnvClaudeConfigDir': 'ไดเรกทอรีการตั้งค่า Claude Code',
'settings.cliEnvCodexHome': 'Codex home',
'settings.cliEnvCodexBin': 'เส้นทางไฟล์เรียกทำงาน Codex',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'กำหนดเอง (พิมพ์ด้านล่าง)…',
'settings.modelCustomLabel': 'ID โมเดลที่กำหนดเอง',
'settings.modelCustomPlaceholder': 'เช่น anthropic/claude-sonnet-4-6',

View file

@ -322,6 +322,7 @@ export const tr: Dict = {
'Modeller kurulu CLI\'dan yenilendi. Varsayılan seçenek hâlâ CLI yapılandırmasını kullanır.',
'settings.modelPickerFallbackHint':
'Yerleşik varsayılanlar gösteriliyor. CLI\'dan canlı modelleri almak için Yeniden tara\'ya tıklayın.',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Özel (aşağıya yazın)…',
'settings.modelCustomLabel': 'Özel model kimliği',
'settings.modelCustomPlaceholder': 'örn. anthropic/claude-sonnet-4-6',

View file

@ -333,6 +333,7 @@ export const uk: Dict = {
'settings.cliEnvCodexBin': 'Codex executable path',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': 'Власна (введіть нижче)…',
'settings.modelCustomLabel': 'Власне ID моделі',
'settings.modelCustomPlaceholder': 'напр. anthropic/claude-sonnet-4-6',

View file

@ -343,6 +343,7 @@ export const zhCN: Dict = {
'settings.cliEnvCodexBin': 'Codex 可执行文件路径',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': '自定义(在下方填写)…',
'settings.modelCustomLabel': '自定义模型 id',
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',

View file

@ -335,6 +335,7 @@ export const zhTW: Dict = {
'settings.cliEnvCodexBin': 'Codex 可執行檔路徑',
'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL',
'settings.cliEnvCodexApiKey': 'Codex/OpenAI proxy API key',
'settings.cliEnvOpenCodeBin': 'OpenCode executable path',
'settings.modelCustom': '自訂(在下方填寫)…',
'settings.modelCustomLabel': '自訂模型 id',
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',

View file

@ -345,6 +345,7 @@ export interface Dict {
'settings.cliEnvCodexBin': string;
'settings.cliEnvCodexBaseUrl': string;
'settings.cliEnvCodexApiKey': string;
'settings.cliEnvOpenCodeBin': string;
'settings.modelCustom': string;
'settings.modelCustomLabel': string;
'settings.modelCustomPlaceholder': string;

View file

@ -1211,6 +1211,70 @@
.agent-install-steps li + li {
margin-top: 4px;
}
.agent-candidates {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.agent-candidates-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.agent-candidates-title {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
}
.agent-candidate-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.agent-candidate-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding-top: 6px;
border-top: 1px solid var(--border-soft);
}
.agent-candidate-row:first-child {
padding-top: 0;
border-top: 0;
}
.agent-candidate-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.agent-candidate-path {
overflow: hidden;
color: var(--text);
font-family: var(--font-mono, monospace);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-candidate-meta {
color: var(--text-muted);
font-size: 11px;
}
.agent-candidate-actions {
display: flex;
align-items: center;
gap: 8px;
}
.agent-candidate-badge {
color: var(--accent-strong);
font-size: 11px;
font-weight: 600;
}
.agent-card-body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.agent-card-name {
display: flex;

View file

@ -1577,7 +1577,7 @@
font-weight: 500;
border-radius: var(--radius-pill);
cursor: pointer;
box-shadow:
box-shadow:
0 4px 12px -3px color-mix(in srgb, var(--accent) 25%, transparent),
var(--shadow-sm);
transition: transform 120ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 120ms cubic-bezier(0.23, 1, 0.32, 1), background-color 120ms cubic-bezier(0.23, 1, 0.32, 1);
@ -1585,7 +1585,7 @@
.designs-empty-cta:hover {
transform: scale(1.02);
box-shadow:
box-shadow:
0 6px 16px -4px color-mix(in srgb, var(--accent) 35%, transparent),
var(--shadow-md);
}

View file

@ -1999,6 +1999,509 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
),
).toBe(false);
});
it('lists OpenCode binary candidates and lets users switch to a detected path', async () => {
const opencode: AgentInfo = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
available: true,
path: '/opt/homebrew/bin/opencode',
version: 'opencode 1.1.14',
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/opt/homebrew/bin/opencode',
bin: 'opencode',
version: 'opencode 1.1.14',
source: 'path',
selected: true,
available: true,
},
{
path: '/Users/mac/.opencode/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.0',
source: 'known',
selected: false,
available: true,
},
],
};
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'opencode' },
{ agents: [opencode] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(screen.getByText('Detected OpenCode binaries')).toBeTruthy();
expect(screen.getByText('/opt/homebrew/bin/opencode')).toBeTruthy();
expect(screen.getByText('/Users/mac/.opencode/bin/opencode')).toBeTruthy();
expect(screen.getByText(/opencode 1\.1\.14 · the PATH OpenCode CLI/)).toBeTruthy();
expect(screen.getByText(/opencode 1\.2\.0 · the known OpenCode install/)).toBeTruthy();
expect(screen.queryByText(/opencode 1\.1\.14 · path/)).toBeNull();
expect(screen.queryByText(/opencode 1\.2\.0 · known/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Use OpenCode binary \/Users\/mac\/\.opencode\/bin\/opencode/i }));
await waitForPersist(
onPersist,
expect.objectContaining({
agentCliEnv: {
opencode: {
OPENCODE_BIN: '/Users/mac/.opencode/bin/opencode',
},
},
}),
{},
);
});
it('lists OpenCode binary candidates when the configured binary is unavailable', async () => {
const opencode: AgentInfo = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
available: false,
path: '/opt/homebrew/bin/opencode',
version: null,
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/opt/homebrew/bin/opencode',
bin: 'opencode',
version: null,
source: 'configured',
selected: true,
available: false,
},
{
path: '/Users/mac/.opencode/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.0',
source: 'known',
selected: false,
available: true,
},
],
};
const { onPersist } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: '/opt/homebrew/bin/opencode',
},
},
},
{ agents: [opencode] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*0 installed/i }));
expect(screen.getByText('Detected OpenCode binaries')).toBeTruthy();
expect(screen.getByText('/opt/homebrew/bin/opencode')).toBeTruthy();
expect(screen.getByText('/Users/mac/.opencode/bin/opencode')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Use OpenCode binary \/Users\/mac\/\.opencode\/bin\/opencode/i }));
await waitForPersist(
onPersist,
expect.objectContaining({
agentCliEnv: {
opencode: {
OPENCODE_BIN: '/Users/mac/.opencode/bin/opencode',
},
},
}),
{},
);
});
it('offers Try next candidate after an OpenCode connection-test failure', async () => {
const opencode: AgentInfo = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
available: true,
path: '/opt/homebrew/bin/opencode',
version: 'opencode 1.1.14',
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/opt/homebrew/bin/opencode',
bin: 'opencode',
version: 'opencode 1.1.14',
source: 'path',
selected: true,
available: true,
},
{
path: '/Users/mac/.opencode/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.0',
source: 'known',
selected: false,
available: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
ok: false,
kind: 'agent_spawn_failed',
latencyMs: 14,
agentName: 'OpenCode',
model: 'default',
detail: 'exit 1 · stderr: incompatible OpenCode binary. Used OpenCode binary: /opt/homebrew/bin/opencode (opencode 1.1.14).',
usedExecutableSource: 'path',
usedExecutablePath: '/opt/homebrew/bin/opencode',
detectedExecutablePath: '/opt/homebrew/bin/opencode',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'opencode' },
{ agents: [opencode] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
expect(screen.getByText(/Used OpenCode binary: \/opt\/homebrew\/bin\/opencode/)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /Try next candidate/i }));
await waitForPersist(
onPersist,
expect.objectContaining({
agentCliEnv: {
opencode: {
OPENCODE_BIN: '/Users/mac/.opencode/bin/opencode',
},
},
}),
{},
);
});
it('uses the selected wrapper candidate when a failed Codex test reports a resolved native binary', async () => {
const codex: AgentInfo = {
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
path: '/Users/mac/.codex/bin/codex',
version: '0.80.0',
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/Users/mac/.codex/bin/codex',
bin: 'codex',
version: '0.80.0',
source: 'known',
selected: true,
available: true,
},
{
path: '/opt/homebrew/bin/codex',
bin: 'codex',
version: '0.81.0',
source: 'path',
selected: false,
available: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
ok: false,
kind: 'agent_spawn_failed',
latencyMs: 14,
agentName: 'Codex CLI',
model: 'default',
detail:
'spawn failed. Used Codex CLI binary: /opt/homebrew/Cellar/codex/0.80.0/bin/codex.',
usedExecutableSource: 'known',
usedExecutablePath: '/opt/homebrew/Cellar/codex/0.80.0/bin/codex',
detectedExecutablePath: '/opt/homebrew/Cellar/codex/0.80.0/bin/codex',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ agents: [codex] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
expect(screen.getByText(/Used Codex CLI binary: \/opt\/homebrew\/Cellar\/codex/)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /Try next candidate/i }));
await waitForPersist(
onPersist,
expect.objectContaining({
agentCliEnv: {
codex: {
CODEX_BIN: '/opt/homebrew/bin/codex',
},
},
}),
{},
);
});
it('offers Try next candidate for a broken configured OpenCode path with one healthy alternate', async () => {
const opencode: AgentInfo = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
available: true,
path: '/opt/homebrew/bin/opencode',
version: null,
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/opt/homebrew/bin/opencode',
bin: 'opencode',
version: null,
source: 'configured',
selected: true,
available: false,
},
{
path: '/Users/mac/.opencode/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.0',
source: 'known',
selected: false,
available: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
ok: false,
kind: 'agent_spawn_failed',
latencyMs: 14,
agentName: 'OpenCode',
model: 'default',
detail: 'spawn failed. Used OpenCode binary: /opt/homebrew/bin/opencode.',
usedExecutableSource: 'configured',
usedExecutablePath: '/opt/homebrew/bin/opencode',
detectedExecutablePath: '/Users/mac/.opencode/bin/opencode',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const { onPersist } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'opencode',
agentCliEnv: {
opencode: {
OPENCODE_BIN: '/opt/homebrew/bin/opencode',
},
},
},
{ agents: [opencode] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
expect(screen.getByText(/Used OpenCode binary: \/opt\/homebrew\/bin\/opencode/)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /Try next candidate/i }));
await waitForPersist(
onPersist,
expect.objectContaining({
agentCliEnv: {
opencode: {
OPENCODE_BIN: '/Users/mac/.opencode/bin/opencode',
},
},
}),
{},
);
});
it.each([
['agent_auth_required', 'Run opencode auth login first.'],
['not_found_model', 'Selected model is not available.'],
] as const)(
'does not offer Try next candidate for OpenCode %s failures',
async (kind, detail) => {
const opencode: AgentInfo = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
available: true,
path: '/opt/homebrew/bin/opencode',
version: 'opencode 1.2.0',
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/opt/homebrew/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.0',
source: 'path',
selected: true,
available: true,
},
{
path: '/Users/mac/.opencode/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.1',
source: 'known',
selected: false,
available: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
ok: false,
kind,
latencyMs: 18,
agentName: 'OpenCode',
model: 'default',
detail,
usedExecutableSource: 'path',
usedExecutablePath: '/opt/homebrew/bin/opencode',
detectedExecutablePath: '/opt/homebrew/bin/opencode',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'opencode' },
{ agents: [opencode] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Retry test/i })).toBeTruthy();
});
expect(screen.queryByRole('button', { name: /Try next candidate/i })).toBeNull();
},
);
it('describes known OpenCode install fallbacks without calling them PATH binaries', async () => {
const opencode: AgentInfo = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
available: true,
path: '/Users/mac/.opencode/bin/opencode',
version: 'opencode 1.2.0',
models: [{ id: 'default', label: 'Default' }],
executableCandidates: [
{
path: '/Users/mac/.opencode/bin/opencode',
bin: 'opencode',
version: 'opencode 1.2.0',
source: 'known',
selected: true,
available: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
ok: true,
kind: 'success',
latencyMs: 22,
agentName: 'OpenCode',
model: 'default',
sample: 'ok',
usedExecutableSource: 'fallback_failed',
configuredExecutablePath: '/opt/homebrew/bin/opencode',
detectedExecutablePath: '/Users/mac/.opencode/bin/opencode',
detectedExecutableSource: 'known',
usedExecutablePath: '/Users/mac/.opencode/bin/opencode',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'opencode' },
{ agents: [opencode] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
expect(screen.getByText(/known OpenCode install at \/Users\/mac\/\.opencode\/bin\/opencode/)).toBeTruthy();
});
expect(screen.queryByText(/PATH OpenCode CLI/)).toBeNull();
});
});
describe('SettingsDialog media providers interactions', () => {

View file

@ -220,8 +220,9 @@ export interface ConnectionTestResponse {
// or required a PATH fallback.
configuredExecutablePath?: string;
detectedExecutablePath?: string;
detectedExecutableSource?: 'path' | 'fallback' | 'known';
usedExecutablePath?: string;
usedExecutableSource?: 'configured' | 'path' | 'fallback_invalid' | 'fallback_failed';
usedExecutableSource?: 'configured' | 'path' | 'fallback' | 'known' | 'fallback_invalid' | 'fallback_failed';
// Structured diagnostics for the local agent connection test path
// (#2248). Optional and additive: existing consumers that only read
// `kind` and `detail` keep working unchanged. Populated on local

View file

@ -3,6 +3,21 @@ export interface AgentModelOption {
label: string;
}
export type AgentExecutableCandidateSource =
| 'configured'
| 'path'
| 'fallback'
| 'known';
export interface AgentExecutableCandidate {
path: string;
bin: string;
source: AgentExecutableCandidateSource;
available: boolean;
selected: boolean;
version?: string | null;
}
export interface AgentInfo {
id: string;
name: string;
@ -15,6 +30,7 @@ export interface AgentInfo {
models?: AgentModelOption[];
/** Whether models came from the installed CLI or Open Design's static fallback. */
modelsSource?: 'live' | 'fallback';
executableCandidates?: AgentExecutableCandidate[];
reasoningOptions?: AgentModelOption[];
/** HTTPS URL to install or download the CLI (vendor docs, GitHub README, npm). */
installUrl?: string;