open-design/apps/daemon/src/runtimes/detection.ts
mehmet turac 8448b1105c
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
fix: preserve OpenClaude fallback credentials (#3361)
2026-05-31 03:49:25 +00:00

257 lines
9.2 KiB
TypeScript

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 { spawnEnvForAgent } from './env.js';
import { probeAgentAuthStatus } from './auth.js';
import { agentCapabilities } from './capabilities.js';
import { installMetaForAgent } from './metadata.js';
import type {
DetectedAgent,
RuntimeAgentDef,
RuntimeCapabilityMap,
RuntimeModelSource,
RuntimeModelOption,
} from './types.js';
type FetchedRuntimeModels = {
models: RuntimeModelOption[];
source: RuntimeModelSource;
};
async function fetchModels(
def: RuntimeAgentDef,
resolvedBin: string,
env: NodeJS.ProcessEnv,
): Promise<FetchedRuntimeModels> {
if (typeof def.fetchModels === 'function') {
try {
const parsed = await def.fetchModels(resolvedBin, env);
if (!parsed || parsed.length === 0) {
return { models: def.fallbackModels, source: 'fallback' };
}
return { models: parsed, source: 'live' };
} catch {
return { models: def.fallbackModels, source: 'fallback' };
}
}
if (!def.listModels) {
return { models: def.fallbackModels, source: 'fallback' };
}
try {
const { stdout } = await execAgentFile(resolvedBin, def.listModels.args, {
env,
timeout: def.listModels.timeoutMs ?? 5000,
// Models lists from popular CLIs (e.g. opencode) easily exceed the
// default 1MB buffer once you include every openrouter model. Bump
// it so we don't truncate the listing.
maxBuffer: 8 * 1024 * 1024,
});
const parsed = def.listModels.parse(String(stdout));
// Empty / null parse result means the CLI didn't actually return a
// usable list (e.g. cursor-agent's "No models available"); fall back
// to the static hint so the picker isn't stuck on Default-only.
if (!parsed || parsed.length === 0) {
return { models: def.fallbackModels, source: 'fallback' };
}
return { models: parsed, source: 'live' };
} catch {
return { models: def.fallbackModels, source: 'fallback' };
}
}
type VersionProbeOutcome =
| { kind: 'not-invocable' }
| { kind: 'spawned'; version: string | null };
/**
* Run the agent's `--version` probe and classify the result. The probe
* has two distinct failure modes the catch arm has to discriminate:
*
* - **Not invocable.** The OS rejected the spawn outright (ENOENT
* for a vanished target, EACCES for a stripped-x bit, ENOTDIR
* for a broken parent), OR the wrapper script spawned but its
* underlying interpreter / target is missing and the shim exits
* with code 127 ("command not found") / 126 ("not executable").
* 127 is the canonical POSIX shell signal for "I ran but the
* thing I delegate to is gone"; 126 is the perm/not-a-binary
* sibling. Both shapes are reproducible by leftover npm bin
* shims, mise/nvm/fnm pointer files, and Windows `.CMD` shims
* whose target was uninstalled. We mark the agent unavailable
* so Settings does not advertise a ghost entry (issue #658,
* lefarcen review P2 on PR #1301).
*
* - **Spawned but `--version` was unhappy.** The binary itself ran
* (any other rejection: timeout, generic non-zero exit, stderr
* noise) so the CLI is invocable; we just can't read a version
* string. Adapters whose `--version` flag is unsupported land
* here and must keep working with `version: null`.
*
* `child_process.execFile` reports OS-level rejections with a string
* `err.code` (`'ENOENT'`, `'EACCES'`, `'ENOTDIR'`) and non-zero exit
* codes with a *numeric* `err.code` equal to the exit status, so the
* two arms below are unambiguous.
*/
async function probeVersionAtPath(
def: RuntimeAgentDef,
resolved: string,
env: NodeJS.ProcessEnv,
): Promise<VersionProbeOutcome> {
try {
const { stdout } = await execAgentFile(resolved, def.versionArgs, {
env,
timeout: def.versionProbeTimeoutMs ?? 3000,
});
const version = String(stdout).trim().split('\n')[0] ?? null;
return { kind: 'spawned', version };
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (typeof code === 'string') {
if (code === 'ENOENT' || code === 'EACCES' || code === 'ENOTDIR') {
return { kind: 'not-invocable' };
}
} else if (typeof code === 'number' && (code === 126 || code === 127)) {
return { kind: 'not-invocable' };
}
return { kind: 'spawned', version: null };
}
}
function unavailableAgent(def: RuntimeAgentDef): DetectedAgent {
return {
...stripFns(def),
models: def.fallbackModels ?? [DEFAULT_MODEL_OPTION],
modelsSource: 'fallback',
available: false,
...installMetaForAgent(def.id),
};
}
async function probe(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): Promise<DetectedAgent> {
// Detection must probe the exact path the runtime will spawn, not just the
// PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise:
// the discovered `codex` entry is often a `#!/usr/bin/env node` wrapper
// that is not invocable from a GUI-launched app's stripped PATH, while the
// launch resolver can still upgrade it to the packaged native Codex binary.
// 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);
if (!launch.selectedPath || !launch.launchPath) {
return unavailableAgent(def);
}
const probeEnv = applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
if (outcome.kind === 'not-invocable') {
return unavailableAgent(def);
}
// 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, {
env: probeEnv,
timeout: 5000,
maxBuffer: 4 * 1024 * 1024,
});
for (const [flag, key] of Object.entries(def.capabilityFlags)) {
caps[key] = String(stdout).includes(flag);
}
} catch {
// If --help fails, leave caps empty so buildArgs falls back to the safe
// baseline (no optional flags).
}
agentCapabilities.set(def.id, caps);
}
const modelResult = await fetchModels(def, launch.launchPath, probeEnv);
const auth = await probeAgentAuthStatus(def.id, launch.launchPath, probeEnv);
return {
...stripFns(def),
models: modelResult.models,
modelsSource: modelResult.source,
available: true,
path: launch.selectedPath,
version: outcome.version,
...(auth
? {
authStatus: auth.status,
...(auth.message ? { authMessage: auth.message } : {}),
}
: {}),
...installMetaForAgent(def.id),
};
}
function stripFns(
def: RuntimeAgentDef,
): Omit<DetectedAgent, 'models' | 'modelsSource' | 'available' | 'path' | 'version'> {
// Drop the buildArgs / listModels closures but keep declarative metadata
// (reasoningOptions, streamFormat, name, bin, etc.). `models` is
// populated separately by `fetchModels`, so we strip the static
// `fallbackModels` slot here too. `helpArgs` / `capabilityFlags` /
// `fallbackBins` / `maxPromptArgBytes` / `env` are probe-or-spawn-only
// metadata and shouldn't bleed into the API response either.
const {
buildArgs,
listModels,
fetchModels,
fallbackModels,
helpArgs,
capabilityFlags,
fallbackBins,
versionProbeTimeoutMs,
maxPromptArgBytes,
env,
...rest
} = def;
return rest;
}
async function safeProbe(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): Promise<DetectedAgent> {
try {
return await probe(def, configuredEnv);
} catch {
// 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
// post-launch probes — must not collapse the whole agent picker.
// Without this guard the bare `Promise.all` rejected and the
// `/api/agents` catch arm returned `[]`, so the UI silently lost
// every CLI option and fell back to BYOK / Cloud only.
return unavailableAgent(def);
}
}
export async function detectAgents(
configuredEnvByAgent: Record<string, Record<string, string>> = {},
) {
const results = await Promise.all(
AGENT_DEFS.map((def) => safeProbe(def, configuredEnvByAgent?.[def.id] ?? {})),
);
// Refresh the validation cache from whatever we just surfaced to the UI
// so /api/chat can accept any model the user could have just picked,
// including ones that only showed up after a CLI re-auth.
for (const agent of results) {
rememberLiveModels(agent.id, agent.models);
}
return results;
}