fix(local-agents): don't 500 /detect when a version-probe spawn is denied

detectOne() ran the `--version` probe via execFile INSIDE a `new Promise`
executor but only handled the async error path. execFile can throw
SYNCHRONOUSLY (e.g. `spawn EPERM` when child-process creation is restricted),
and a synchronous throw in a Promise executor REJECTS the promise. Because
detectLocalAgents() used Promise.all, one agent's spawn failure rejected the
whole batch → GET /api/agents/local/detect returned 500 → the Settings "Local
Agents" card silently rendered "0/0 installed" even though all CLIs were on PATH.

- detectOne: wrap execFile in try/catch. The binary was already found on PATH
  (resolveBin returned a path) so it IS installed; on a sync throw we resolve
  installed:true with version '?' and filesystem-based auth, never rejecting.
- detectLocalAgents: Promise.allSettled instead of Promise.all, so one agent's
  failure degrades to "not installed" for that agent only, never the batch.

Verified: /detect returns 200 with claude/codex/hermes installed+ready.
Typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
psighoandClaude Opus 4.8 committed 2026-07-10 03:22:46 +05:00
1 parent 6ba02ec50d
commit a836e61adb
1 file changed
+31 -8
+31 -8
View File
@@ -273,6 +273,17 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
resolve({ ...base, installed: false, authed: false, ready: false }); resolve({ ...base, installed: false, authed: false, ready: false });
return; return;
} }
// Found on PATH ⇒ installed. Used when the version probe can't RUN (a synchronous spawn
// throw like EPERM, or any async error that isn't ENOENT): the binary exists, we just
// couldn't read its version. Auth is a filesystem/keychain check, so it still works.
const installedNoVersion = () => {
const auth = authState(spec);
resolve({
...base, installed: true, path: resolved, version: '?',
authed: auth.authed, authMethod: auth.method, ready: auth.authed,
});
};
try {
// Launch the RESOLVED path (a .cmd shim needs a shell; a real .exe does not). // Launch the RESOLVED path (a .cmd shim needs a shell; a real .exe does not).
execFile(resolved, spec.versionArgs, { timeout: 8000, shell: needsShell(resolved) }, (err, stdout) => { execFile(resolved, spec.versionArgs, { timeout: 8000, shell: needsShell(resolved) }, (err, stdout) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') { if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -283,15 +294,17 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
const version = spec.parseVersion(String(stdout || '')); const version = spec.parseVersion(String(stdout || ''));
const auth = authState(spec); const auth = authState(spec);
resolve({ resolve({
...base, ...base, installed: true, path: resolved, version,
installed: true, authed: auth.authed, authMethod: auth.method, ready: auth.authed,
path: resolved,
version,
authed: auth.authed,
authMethod: auth.method,
ready: auth.authed,
}); });
}); });
} catch {
// execFile can throw SYNCHRONOUSLY (e.g. `spawn EPERM` when child-process creation is
// restricted). Inside this Promise executor a throw would REJECT the promise, which fails
// the Promise.allSettled sibling below and — pre-fix, under Promise.all — 500'd the whole
// /api/agents/local/detect endpoint, blanking the Settings panel to "0/0 installed".
installedNoVersion();
}
}); });
} }
@@ -301,7 +314,17 @@ export async function detectLocalAgents(): Promise<AgentDetection[]> {
// local-agent auto-detection) so key-required / fail-closed paths can be exercised // local-agent auto-detection) so key-required / fail-closed paths can be exercised
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run. // deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return []; if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
return Promise.all(SPECS.map(detectOne)); // allSettled, NOT all: a single agent's detection failure must degrade to "not installed"
// for that one agent, never reject the whole batch (which 500'd /detect and blanked the UI).
const settled = await Promise.allSettled(SPECS.map(detectOne));
return settled.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
const s = SPECS[i];
return {
id: s.id, label: s.label, vendor: s.vendor, bin: s.bin, blurb: s.blurb,
invokeHint: s.invokeHint, installed: false, authed: false, ready: false,
};
});
} }
export interface AgentRunResult { export interface AgentRunResult {