coder(providers): v2.3 provider-lifecycle phase 1 — config-backed registry

Adds a config layer merged over the hardcoded built-ins (tasks 1.1-1.6): CODER_PROVIDERS_PATH env (default /data/coder-providers.json); provider-config.ts (Zod schema + never-throw loader — missing/invalid file falls back to built-ins only — + save); provider-config-registry.ts (ResolvedProviderDef + buildResolvedRegistry merge: override built-ins, add custom extends:'acp' entries, boocode always enabled + singleton); agent-probe now iterates the resolved registry, probes custom-ACP command[0] via execFile (no shell), skips disabled providers (keeps the row), reads enabled from memory only (no DB column). No snapshot/dispatch/route/UI changes (Phase 2+). 6 new unit tests; empty config provably yields exactly the built-ins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 04:07:43 +00:00
parent a359a4ab8b
commit 3730dc9341
7 changed files with 346 additions and 24 deletions

View File

@@ -1,24 +1,34 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb } from 'node:child_process';
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { loadConfig } from '../config.js';
import { loadProviderConfig } from './provider-config-registry.js';
const exec = promisify(execCb);
const execFile = promisify(execFileCb);
// `which` via execFile (no shell) — the binary name can come from the config
// file (custom ACP entries), so avoid interpolating it into a shell string.
async function whichBinary(bin: string): Promise<string | null> {
try {
const { stdout } = await execFile('which', [bin], { timeout: 10_000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
async function resolveInstallPath(agentName: string): Promise<string | null> {
const candidates = resolveAcpProbeBinaries(agentName);
for (const bin of candidates) {
try {
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
const path = stdout.trim();
if (path) return path;
} catch {
/* try next */
}
const path = await whichBinary(bin);
if (path) return path;
}
return null;
}
@@ -46,14 +56,37 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
/**
* Probe for available agents on the HOST.
*
* v2.3: iterates the resolved provider registry (built-ins + config-backed
* custom ACP entries) rather than the hardcoded `PROBED_AGENT_NAMES`. Native
* boocode is not probed; disabled providers are skipped (their `available_agents`
* row is kept, not deleted). `enabled` is read from the in-memory registry only —
* no DB column in Phase 1 (design.md §3.3).
*/
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
clearProviderSnapshotCache();
log.info('agent-probe: scanning for known agents');
for (const agentName of PROBED_AGENT_NAMES) {
const registry = loadProviderConfig(loadConfig().CODER_PROVIDERS_PATH);
for (const resolved of registry.values()) {
const agentName = resolved.id;
// Native boocode is not a probed host agent.
if (resolved.transport === 'native') continue;
// Disabled providers: skip the probe, keep any existing row.
if (!resolved.enabled) {
log.info({ agent: agentName }, 'agent-probe: skipping disabled provider');
continue;
}
try {
const installPath = await resolveInstallPath(agentName);
// Custom ACP entries resolve their binary from command[0]; built-ins use
// the per-agent probe binaries.
const installPath = resolved.isCustomAcp && resolved.launchCommand
? await whichBinary(resolved.launchCommand[0])
: await resolveInstallPath(agentName);
if (!installPath) continue;
let version: string | null = null;
@@ -64,24 +97,34 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
/* optional */
}
const providerDef = PROVIDERS_BY_NAME.get(agentName);
let supportsAcp = providerDef?.transport === 'acp';
if (supportsAcp) {
supportsAcp = await detectAcpSupport(agentName, installPath);
// Custom ACP entries are ACP by declaration; built-ins detect support.
let supportsAcp: boolean;
if (resolved.isCustomAcp) {
supportsAcp = true;
} else {
supportsAcp = resolved.transport === 'acp';
if (supportsAcp) {
supportsAcp = await detectAcpSupport(agentName, installPath);
}
}
let models: Array<{ id: string; label: string }> = [];
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
if (!resolved.isCustomAcp) {
const providerDef = PROVIDERS_BY_NAME.get(agentName);
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
}
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
}
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
const label = providerDef?.label ?? agentName;
const transport =
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
const label = resolved.configLabel ?? resolved.label;
const transport = resolved.isCustomAcp
? 'acp'
: resolved.transport === 'acp' && !supportsAcp
? 'pty'
: (resolved.transport ?? 'pty');
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)