mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge c4aac5f92b into 53fb175855
This commit is contained in:
commit
1b92dc9f80
36 changed files with 2607 additions and 153 deletions
|
|
@ -7,6 +7,7 @@ export {
|
|||
export { detectAgents } from './runtimes/detection.js';
|
||||
export {
|
||||
resolveOnPath,
|
||||
inspectAgentExecutableCandidates,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentExecutable,
|
||||
} from './runtimes/executables.js';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ export const fr: Dict = {
|
|||
'settings.cliEnvCodexBin': 'Chemin de l’exé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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue