import { accessSync, closeSync, constants, existsSync, openSync, readSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { getManifest, type ProviderProbeFeatureCheck } from "agent-relay-providers"; import { providerCatalogList, type ProviderCatalogEntry } from "agent-relay-sdk/provider-catalog"; import { errMessage, type SpawnProvider } from "agent-relay-sdk"; import type { OrchestratorConfig } from "./config"; import { VERSION } from "./version"; interface ProviderProbeDetail { command: string; path?: string; ok: boolean; version?: string; error?: string; } interface ProviderProbeResult { name: SpawnProvider; available: boolean; checkedAt: number; reason?: string; version?: string; runnerVersion: string; features?: Record; cli: ProviderProbeDetail; runner: ProviderProbeDetail; /** * #1254 — present only when the provider manifest declares `probe.authCheck`. Absent means "no * local login-validity signal for this provider", not "assumed valid". `available` deliberately * does NOT fold this in: a host can have a perfectly working CLI with an expired login (exactly * the zombie-worker bug), so callers that need the hard auth gate should use `checkProviderAuth`. */ auth?: ProviderProbeDetail; } /** #1254 — the spawn-time auth gate's result: whether a login-validity check ran, and if so, its outcome. */ export interface ProviderAuthCheckOutcome { checked: boolean; ok: boolean; reason?: string; } interface ProviderProbeSnapshot { providers: SpawnProvider[]; providerStatus: ProviderProbeResult[]; providerCatalog: ProviderCatalogEntry[]; checkedAt: number; } const DEFAULT_TTL_MS = 30_000; const DEFAULT_TIMEOUT_MS = 2_500; export class ProviderProbeCache { private snapshot?: ProviderProbeSnapshot; constructor( private readonly config: OrchestratorConfig, private readonly ttlMs = DEFAULT_TTL_MS, private readonly timeoutMs = DEFAULT_TIMEOUT_MS, ) {} async getSnapshot(refresh = false): Promise { const now = Date.now(); if (!refresh && this.snapshot && now - this.snapshot.checkedAt < this.ttlMs) { return this.snapshot; } const providerStatus = await Promise.all(this.config.providers.map((provider) => probeProvider(provider, this.timeoutMs))); const providers = providerStatus.filter((status) => status.available).map((status) => status.name); this.snapshot = { providers, providerStatus, providerCatalog: providerCatalogList().filter((entry) => this.config.providers.includes(entry.provider)), checkedAt: Date.now(), }; return this.snapshot; } } export async function probeProvider(provider: SpawnProvider, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { const manifest = getManifest(provider); const cliCommand = manifest?.probe?.command ?? provider; const cliArgs = manifest?.probe?.args ?? ["--version"]; const authCheck = manifest?.probe?.authCheck; const [cli, runner, auth] = await Promise.all([ probeCommand(cliCommand, cliArgs, timeoutMs, cliCommand, (path) => isRelayProviderShim(path, provider)), probeRunner(provider, timeoutMs), authCheck ? probeCommand(authCheck.command, authCheck.args, timeoutMs, authCheck.command, (path) => isRelayProviderShim(path, provider)) : Promise.resolve(undefined), ]); const available = cli.ok && runner.ok; const features = probeProviderFeatures(manifest?.probe?.featureChecks ?? []); return { name: provider, available, checkedAt: Date.now(), reason: available ? undefined : unavailableReason(cli, runner), version: cli.version, runnerVersion: VERSION, ...(Object.keys(features).length > 0 ? { features } : {}), cli, runner, ...(auth ? { auth } : {}), }; } /** * #1254 — the spawn-time auth gate. Runs the provider manifest's declared `authCheck` (a fast, * local, no-cost command — e.g. `claude auth status --json` / `codex login status`) and reports * whether the login is valid. Returns `{ checked: false, ok: true }` when the manifest declares no * authCheck (nothing to validate locally) rather than assuming a valid login. */ export async function checkProviderAuth(provider: SpawnProvider, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { const authCheck = getManifest(provider)?.probe?.authCheck; if (!authCheck) return { checked: false, ok: true }; const detail = await probeCommand(authCheck.command, authCheck.args, timeoutMs, authCheck.command, (path) => isRelayProviderShim(path, provider)); if (detail.ok) return { checked: true, ok: true }; return { checked: true, ok: false, reason: `${provider} login check (\`${authCheck.command} ${authCheck.args.join(" ")}\`) reports the session as invalid or expired` + (detail.error ? `: ${detail.error}` : "") + " — re-authenticate on this host.", }; } function probeProviderFeatures(checks: ProviderProbeFeatureCheck[]): Record { const features: Record = {}; for (const check of checks) { const hasCommand = check.command ? !!resolveExecutable(check.command) : false; const hasHomeDir = check.homeDir ? existsSync(join(homedir(), check.homeDir)) : false; if (hasCommand || hasHomeDir) { features[check.name] = true; } } return features; } async function probeRunner(provider: SpawnProvider, timeoutMs: number): Promise { const repoLauncher = resolve(import.meta.dir, "../../runner/src/index.ts"); if (existsSync(repoLauncher)) { const bun = resolveExecutable("bun"); if (!bun) return { command: "bun", ok: false, error: "bun executable not found" }; return probeCommand(bun, ["run", repoLauncher, provider, "--help"], timeoutMs, `bun run ${repoLauncher}`); } return probeCommand(`${provider}-relay`, ["--help"], timeoutMs); } async function probeCommand( command: string, args: string[], timeoutMs: number, displayCommand = command, skipPath?: (path: string) => boolean, ): Promise { const path = resolveExecutable(command, skipPath); if (!path) return { command: displayCommand, ok: false, error: `${displayCommand} executable not found` }; let proc: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; try { proc = Bun.spawn([path, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); const output = await Promise.race([ processOutput(proc), Bun.sleep(timeoutMs).then(() => "timeout" as const), ]); if (output === "timeout") { try { proc.kill("SIGKILL"); } catch {} return { command: displayCommand, path, ok: false, error: `probe timed out after ${timeoutMs}ms` }; } const { exitCode, stdout, stderr } = output; return { command: displayCommand, path, ok: exitCode === 0, version: firstLine(stdout), error: exitCode === 0 ? undefined : firstLine(stderr) || `exit code ${exitCode}`, }; } catch (error) { try { proc?.kill("SIGKILL"); } catch {} return { command: displayCommand, path, ok: false, error: errMessage(error) }; } } async function processOutput(proc: Bun.Subprocess<"ignore", "pipe", "pipe">): Promise<{ exitCode: number; stdout: string; stderr: string }> { const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); return { stdout, stderr, exitCode }; } function unavailableReason(cli: ProviderProbeDetail, runner: ProviderProbeDetail): string { if (!cli.ok) return cli.error || `${cli.command} unavailable`; if (!runner.ok) return runner.error || `${runner.command} unavailable`; return "provider unavailable"; } function firstLine(value: string): string | undefined { return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean); } export function resolveExecutable(command: string, skipPath?: (path: string) => boolean): string | undefined { if (command.includes("/")) { const path = resolve(command); return isExecutable(path) && !skipPath?.(path) ? path : undefined; } for (const dir of (process.env.PATH || "").split(delimiter).filter(Boolean)) { const path = resolve(dir, command); if (isExecutable(path) && !skipPath?.(path)) return path; } return undefined; } function isExecutable(path: string): boolean { try { accessSync(path, constants.X_OK); return true; } catch { return false; } } // #1571 — the relay shim is a tiny generated text wrapper; a real provider CLI binary is // hundreds of MB. Reading the whole file (previously `readFileSync(path, "utf8")`) to inspect // its first KB balloons RSS by the full binary size on every probe (heartbeat re-probes ~every // 60s), producing a sawtooth of multi-GB allocations reclaimed by GC. Stat first and skip // anything too large to be the shim, then read only a bounded prefix via a raw fd — never the // full content. Results are cached per (provider, resolved path): the shim/binary at a given // path doesn't change within a process lifetime. const SHIM_PROBE_BYTES = 1024; const SHIM_MAX_FILE_BYTES = 64 * 1024; const shimCache = new Map(); export function isRelayProviderShim(path: string, provider: SpawnProvider): boolean { const cacheKey = `${provider} ${path}`; const cached = shimCache.get(cacheKey); if (cached !== undefined) return cached; const result = detectRelayProviderShim(path, provider); shimCache.set(cacheKey, result); return result; } function detectRelayProviderShim(path: string, provider: SpawnProvider): boolean { try { const size = statSync(path).size; if (size === 0 || size > SHIM_MAX_FILE_BYTES) return false; const buffer = Buffer.alloc(Math.min(size, SHIM_PROBE_BYTES)); const fd = openSync(path, "r"); try { readSync(fd, buffer, 0, buffer.length, 0); } finally { closeSync(fd); } if (buffer.includes(0)) return false; // binary content — not the text shim wrapper return buffer.toString("utf8").includes(`${provider}-relay ${provider}`); } catch { return false; } }