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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user