Files
Codeman/scripts/test-local-llm-harnesses.ts
T
Codeman maintainer 942bf37e48 fix(custom-model): unset injected env on clear, resume on restart, select the model for pi/omp/grok
Custom Model Endpoint Profiles (#393) let a session point its CLI at a
custom OpenAI-compatible endpoint by injecting env vars or a config file
and restarting the CLI in place. Review of the apply path found four
things, two of them destructive. This lands all four plus the smaller
items from the same review.

1. Clearing a selection did not clear it. The injected vars reach the CLI
   via `tmux setenv`, which persists at the tmux-session level and is
   inherited by `respawn-pane` (measured: `setenv FOO bar` survived two
   successive `respawn-pane -k`), so deleting the keys from the session's
   envOverrides relaunched the CLI still pointed at the old endpoint, and
   for the configDir kinds at a HOME/CODEX_HOME/GROK_HOME that had just
   been deleted. `Session.setCustomModel()` now reports the removed keys,
   queues them (`_pendingEnvUnsets`), and `RespawnPaneOptions.unsetEnvKeys`
   carries them into `applyEnvOverrides()`, which `setenv -u`s them before
   re-applying the live overrides, on the same path that already unsets
   the legacy CLAUDE_CODE_EFFORT_LEVEL. Verified on a private tmux socket
   that `setenv -u HOME` hands the next respawn the global HOME back.

2. Applying a model to a local claude session killed the pane. The
   relaunch was `claude --session-id <id>` and Claude refuses an id that
   already has a transcript, and unlike the dead-pane respawn this one
   kills a working pane first. `restartCli()` now pins the live
   conversation id as the resume id for that respawn when the CLI's launch
   declares a `fallback` chain, which renders the same
   `--resume <id> || --session-id <id>` shape the docker and remote pane
   commands use. Gated on the registry shape, not the CLI id: an entry
   whose resume id is minted by the CLI itself never declares that chain.

3. pi, omp and grok wrote their config file and then launched without the
   `--model` that selects it, so the file was ignored. The registry entry
   now declares `customModelInjection.launchModel` (`custom/{modelId}` for
   pi and omp, grok's `[model.codeman-custom]` block name), the builder
   renders it, and `_withCustomModelLaunchModel()` applies it onto the
   respawn options through `legacyConfigField`, leaving the stored
   <Mode>Config untouched so a clear falls back to the user's own model.
   A model id the CLI's `model` token pattern cannot carry is refused
   with a 400 rather than silently dropped by the argv engine.

4. Remote (SSH) and Docker sessions reported `restarted: true` and changed
   nothing: their `restartCli()` reattaches the durable tmux rather than
   relaunching the agent, and the env lands on the local pane. Both are
   refused with a 400 until those paths are plumbed.

Smaller items from the same review:

- The selection survives a Codeman restart as the disk-only `__customModel`
  bookkeeping (endpoint, model, injected key NAMES, config dir, launch
  model; never the values, which carry the API key). Recovery re-derives
  the values from the endpoint store through the same apply path the route
  uses and keeps the bookkeeping even when the endpoint is gone, so a
  later clear still has keys to unset.
- Discovery goes through `webviewFetch()`, so the RESOLVED address is
  judged by the same egress guard the web-tab proxy uses, and `baseUrl`
  reuses `webviewUrlSchema` (http(s) only, no embedded credentials,
  link-local and cloud-metadata addresses refused). undici's `fetch failed`
  wrapper is unwrapped so the user sees the ECONNREFUSED underneath.
- `custom-model-hosts.json` is written 0600 via tmp+rename, the per-session
  config dir 0700/0600 (pi and omp embed the key literally), and that dir
  is removed with the session.
- `PR.md` is gone from the repo root and the design doc moved to
  `docs/custom-model-endpoints-plan.md` with the LAN address and the
  personal name scrubbed; every reference follows. The guide's `authStyle`
  text matches the shipped schema (`bearer | api-key`, default `bearer`)
  and says that `customModelEndpointsEnabled` is read by nothing until
  the picker lands.
- `config/tsconfig.scripts.json` typechecks `scripts/test-local-llm-harnesses.ts`
  (four real type errors fixed). It is not yet wired into `npm run typecheck`
  because that line differs on master; adding `&& tsc -p config/tsconfig.scripts.json`
  there is the one-line follow-up.

Tests: `test/session-custom-model-restart.test.ts` drives a real Session and
fails on the unfixed code for items 1 to 3; the route suite covers item 4
and the pattern refusal; `test/tmux-manager.test.ts` pins that the unsets
run before the overrides and that a shell-metachar key never reaches tmux.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:46:28 +02:00

700 lines
29 KiB
TypeScript

#!/usr/bin/env -S npx tsx
/**
* Standalone smoke-test for pointing each Codeman-supported harness CLI at a
* custom OpenAI-compatible endpoint — local (llama.cpp, Ollama, vLLM, ...) or
* cloud (Azure AI Foundry's OpenAI-compatible endpoint, OpenRouter, a
* self-hosted gateway, ...). Anything that answers GET /v1/models and POST
* /v1/chat/completions in the standard shape qualifies; --base-url is not
* assumed to be a LAN address.
*
* This is intentionally OUTSIDE the npm test suite and outside Codeman's own
* session/tmux machinery: it spawns each real CLI binary directly, one-shot,
* with the env vars / config files that CLI's own docs say redirect it to a
* custom endpoint, and checks it can answer "hello world".
*
* DYNAMIC BY DESIGN: this file imports the SAME `enabledClis()` registry and
* `buildCustomModelInjection()` builder the production feature uses (see
* ../src/config/cli-registry/, ../src/custom-model-injection.ts,
* ../src/custom-model-injection-apply.ts) rather than keeping a second,
* hand-maintained copy of each CLI's env vars/config shape. A registry
* change (a new CLI, an edited env var name, a fixed config template) is
* picked up here automatically with zero edits to this file. Only the
* ONE-SHOT INVOCATION FLAGS (how to make each CLI answer one prompt and
* exit — information the registry doesn't model at all, since it only knows
* how to launch the interactive TUI) stay in the small ONE_SHOT table below;
* a CLI newly added to the registry with no ONE_SHOT entry is reported
* UNKNOWN rather than silently skipped or guessed at.
*
* Cloud endpoints often differ from a bare llama.cpp box in two ways this
* script accounts for: (1) auth may be an `api-key` header (Azure's
* convention) rather than `Authorization: Bearer` — see --auth-style below.
* (2) a cloud endpoint's "model" may actually be a deployment name distinct
* from the model family (Azure AI Foundry deployments) — always pass
* --model explicitly for those rather than relying on GET /v1/models
* discovery.
*
* IMPORTANT CONFIDENCE NOTE: claude and opencode are verified end-to-end
* against a real llama-swap server. codex's config STRUCTURE is verified,
* but it only speaks the Responses API (dropped Chat-Completions support
* Feb 2026) — expect it to fail against a plain OpenAI-compatible server,
* that's a real protocol gap, not a bug here. gemini/pi/grok/omp have their
* ONE-SHOT INVOCATION flags confirmed against real installed binaries'
* `--help` output, but their custom-endpoint env/config conventions remain
* web-researched, unverified. deepseek (dsh) is a profile launcher with no
* documented one-shot prompt flag at all — best-effort only. antigravity
* has no known CLI/env/config mechanism (GUI-only per public docs) — its
* registry entry declares `customModelInjection: { kind: 'unsupported' }`,
* which this script picks up dynamically and always skips.
*
* Usage:
* npx tsx scripts/test-local-llm-harnesses.ts --base-url http://192.168.1.50:8080 [options]
* npx tsx scripts/test-local-llm-harnesses.ts --base-url https://<resource>.services.ai.azure.com/openai/v1 --model <deployment-name> --api-key $AZURE_AI_KEY
*
* Options:
* --base-url <url> Required. Root URL of the OpenAI-compatible endpoint (local or cloud).
* --model <name> Model/deployment id to request. Default: first from GET /v1/models.
* --api-key <key> API key to send. Default: local-dummy-key (fine for llama.cpp; required for most cloud endpoints).
* --auth-style <style> "bearer" (default, Authorization: Bearer) or "api-key" (the
* `api-key` header some cloud gateways, e.g. Azure, want).
* NEVER send both — live-tested against a real server, doing
* so reliably HANGS the request indefinitely.
* --prompt <text> Prompt to send. Default: "Reply with exactly: hello world".
* --only <id,id,...> Restrict to these harness ids (comma-separated).
* --timeout <ms> Per-harness spawn timeout. Default: 30000.
* --probe-help Instead of testing, resolve each installed binary and print --help.
* --keep-temp Don't delete generated per-harness config dirs afterward.
* --list Dry run: print the resolved plan per harness, execute nothing.
* -h, --help Show this help.
*/
import { execFileSync, spawn } from 'node:child_process';
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join, delimiter, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { enabledClis } from '../src/config/cli-registry/index.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
import {
buildCustomModelInjection,
GROK_CUSTOM_MODEL_NAME,
type CustomModelEndpoint,
} from '../src/custom-model-injection.js';
import { applyConfigDirInjection } from '../src/custom-model-injection-apply.js';
const TAG = '[test-local-llm-harnesses]';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = join(SCRIPT_DIR, 'local-llm-test.config.json');
const CONFIG_EXAMPLE_PATH = join(SCRIPT_DIR, 'local-llm-test.config.example.json');
type AuthStyle = 'bearer' | 'api-key';
interface ConfigDefaults {
baseUrl?: string | null;
model?: string | null;
apiKey?: string;
authStyle?: AuthStyle;
prompt?: string;
only?: string[] | null;
timeout?: number;
}
/**
* Loads scripts/local-llm-test.config.json (gitignored — real IP/model/key,
* per-machine) if present, so you don't have to retype --base-url every run.
* See local-llm-test.config.example.json (tracked) for the shape. CLI flags
* always override whatever this file sets; this only supplies defaults.
*/
function loadConfigFile(): ConfigDefaults {
if (!existsSync(CONFIG_PATH)) return {};
try {
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
return {
baseUrl: raw.baseUrl ?? null,
model: raw.model ?? null,
apiKey: raw.apiKey || undefined, // empty string counts as "not set", not a real key
authStyle: raw.authStyle === 'api-key' ? 'api-key' : undefined, // never 'both'
prompt: raw.prompt ?? undefined,
only: Array.isArray(raw.only) && raw.only.length ? raw.only : null,
timeout: typeof raw.timeout === 'number' ? raw.timeout : undefined,
};
} catch (err) {
console.error(`${TAG} failed to parse ${CONFIG_PATH}: ${(err as Error).message} (ignoring it)`);
return {};
}
}
interface Opts {
baseUrl: string | null;
model: string | null;
apiKey: string;
authStyle: AuthStyle;
prompt: string;
only: string[] | null;
timeout: number;
probeHelp: boolean;
keepTemp: boolean;
list: boolean;
help: boolean;
}
function parseArgs(argv: string[], configDefaults: ConfigDefaults): Opts {
const opts: Opts = {
baseUrl: configDefaults.baseUrl ?? null,
model: configDefaults.model ?? null,
apiKey: configDefaults.apiKey ?? 'local-dummy-key',
authStyle: configDefaults.authStyle ?? 'bearer',
prompt: configDefaults.prompt ?? 'Reply with exactly: hello world',
only: configDefaults.only ?? null,
timeout: configDefaults.timeout ?? 30000,
probeHelp: false,
keepTemp: false,
list: false,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '--base-url':
opts.baseUrl = argv[++i];
break;
case '--model':
opts.model = argv[++i];
break;
case '--api-key':
opts.apiKey = argv[++i];
break;
case '--auth-style':
opts.authStyle = argv[++i] as AuthStyle;
if (opts.authStyle !== 'bearer' && opts.authStyle !== 'api-key') {
console.error(`${TAG} --auth-style must be "bearer" or "api-key"`);
opts.help = true;
}
break;
case '--prompt':
opts.prompt = argv[++i];
break;
case '--only':
opts.only = argv[++i]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
break;
case '--timeout':
opts.timeout = Number(argv[++i]);
break;
case '--probe-help':
opts.probeHelp = true;
break;
case '--keep-temp':
opts.keepTemp = true;
break;
case '--list':
opts.list = true;
break;
case '-h':
case '--help':
opts.help = true;
break;
default:
console.error(`${TAG} unknown argument: ${a}`);
opts.help = true;
}
}
return opts;
}
function printUsage(): void {
console.log(`Usage: npx tsx scripts/test-local-llm-harnesses.ts [--base-url <url>] [options]
Reads defaults from scripts/local-llm-test.config.json if it exists (copy
scripts/local-llm-test.config.example.json to create it — gitignored, since
it holds a real IP/model/key). CLI flags always override the config file.
--base-url becomes optional once that file supplies one.
Works against any custom OpenAI-compatible endpoint, local or cloud
(llama.cpp, Ollama, vLLM, Azure AI Foundry, OpenRouter, a self-hosted
gateway, ...) — anything answering GET /v1/models and POST
/v1/chat/completions in the standard shape.
Options:
--base-url <url> Required. Root URL of the OpenAI-compatible endpoint.
--model <name> Model/deployment id to request. Default: first from GET /v1/models.
--api-key <key> API key to send. Default: local-dummy-key (required for most cloud endpoints).
--auth-style <style> "bearer" (default) or "api-key" (Azure-style). Never both — sending
both headers together reliably hangs some real servers.
--prompt <text> Prompt to send. Default: "Reply with exactly: hello world".
--only <id,id,...> Restrict to these harness ids.
--timeout <ms> Per-harness spawn timeout. Default: 30000.
--probe-help Print each installed binary's --help instead of testing.
--keep-temp Keep generated per-harness config dirs afterward.
--list Dry run: print the resolved plan, execute nothing.
-h, --help Show this help.
Harness ids are read from the CLI registry at run time — pass an unknown
one and the error message lists what's actually enabled right now.
Examples:
npx tsx scripts/test-local-llm-harnesses.ts --base-url http://192.168.1.50:8080
npx tsx scripts/test-local-llm-harnesses.ts --base-url https://<resource>.services.ai.azure.com/openai/v1 --model <deployment-name> --api-key $AZURE_AI_KEY`);
}
const HOME = homedir();
/** Expands a leading `~` the way the CLI registry's own search dirs are written. */
function expandHome(p: string): string {
if (p === '~') return HOME;
if (p.startsWith('~/')) return join(HOME, p.slice(2));
return p;
}
function pathWithExtraDirs(extraDirs: string[]): string {
return [...extraDirs.map(expandHome), '/usr/local/bin', process.env.PATH ?? ''].join(delimiter);
}
/** Resolve a binary by trying `<bin> --version` with the CLI's own registry search dirs prefixed onto PATH. */
function resolveBinary(bin: string, searchDirs: string[]): string | null {
try {
execFileSync(bin, ['--version'], {
timeout: 5000,
stdio: 'pipe',
env: { ...process.env, PATH: pathWithExtraDirs(searchDirs) },
});
return bin;
} catch (err) {
// Some CLIs (e.g. dsh) don't support --version cleanly for identity but
// still exist on PATH; a non-ENOENT failure still counts as "found".
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') return null;
return bin;
}
}
function printHelp(bin: string, searchDirs: string[]): void {
try {
const out = execFileSync(bin, ['--help'], {
timeout: 5000,
stdio: 'pipe',
env: { ...process.env, PATH: pathWithExtraDirs(searchDirs) },
});
console.log(out.toString());
} catch (err) {
const e = err as { stdout?: Buffer; message?: string };
console.log((e.stdout ?? e.message ?? String(err)).toString());
}
}
// --- one-shot invocation table (NOT in the registry — genuinely separate info) ---
type Confidence = 'verified' | 'researched' | 'unknown';
interface OneShot {
/** `modelId` is the RAW model/deployment id (e.g. "qwen3.5-0.8b-...") — CLIs whose
* config wraps it under a provider/block name (pi/omp's "custom/<id>", grok's fixed
* block name) build the full `--model` value here, not in the injection layer. */
argv: (prompt: string, modelId: string) => string[];
confidence: Confidence;
note?: string;
}
/**
* How to make each CLI answer ONE prompt and exit. The registry has no concept
* of this (it only knows the interactive TUI launch line), so this table is
* necessarily hand-maintained — but it is the ONLY hand-maintained part left;
* everything about WHERE the prompt goes (env vars, config files) comes from
* the real registry + `buildCustomModelInjection()` above.
*
* A CLI enabled in the registry with no entry here reports UNKNOWN rather
* than being silently skipped or guessed at — see `resolveOneShot()`.
*/
const ONE_SHOT: Record<string, OneShot> = {
claude: {
confidence: 'verified',
// Claude Code's async session-title-generation call also uses
// ANTHROPIC_DEFAULT_HAIKU_MODEL and validates it against Claude's OWN internal
// recognized-model list, printing [claude-code:unrecognized_model] to stderr for
// a local model name. Confirmed live: `--settings '{"autoTitle":false}'` does NOT
// stop it (still hung the whole run); `--bare` does — the warning still prints,
// but the actual prompt now runs and returns the real answer. Confirmed against
// a real llama-swap server. ⚠️ `--bare` also disables hooks/LSP/plugin sync/
// CLAUDE.md auto-discovery — fine for this ISOLATED one-shot test, never safe to
// apply to a real interactive Codeman session (which needs hooks).
argv: (prompt) => ['--dangerously-skip-permissions', '--bare', '-p', prompt],
},
opencode: { confidence: 'verified', argv: (prompt) => ['run', prompt] },
codex: {
confidence: 'verified',
note: 'config STRUCTURE verified; codex only speaks the Responses API (dropped Chat-Completions Feb 2026) — expect FAIL against a plain OpenAI-compatible server, that is a protocol gap, not a bug here.',
argv: (prompt) => ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt],
},
gemini: {
confidence: 'researched',
// --skip-trust: without it, an untrusted-folder check silently overrides
// --approval-mode yolo back to 'default' (confirmed live: "Approval mode
// overridden to 'default' because the current folder is not trusted").
argv: (prompt) => ['-p', prompt, '--approval-mode', 'yolo', '--skip-trust'],
},
pi: {
confidence: 'verified',
// --model custom/<id>: without an explicit --model, pi uses its own default
// provider (not our injected "custom" one) and fails with "No API key found
// for the selected model" — confirmed live. "custom" matches the provider name
// pi-models-json writes in custom-model-injection.ts. Verified end-to-end
// against a real llama-swap server after two real bugs were found and fixed:
// pi's `models` field must be an ARRAY of `{id}` objects (an object keyed by
// id silently loaded zero models), and PI_CONFIG_DIR does nothing for pi at
// all (grepped pi's own bundled source — not present anywhere); the actual
// working redirect is the CHILD PROCESS's `HOME` itself, since pi hardcodes
// `~/.pi/agent/models.json` with no dedicated override.
argv: (prompt, modelId) => ['--approve', '--model', `custom/${modelId}`, '-p', prompt],
},
grok: {
confidence: 'verified',
// -m <block name>: grok's config.toml (grok-toml template) declares the custom
// model under a fixed [model.<name>] block; GROK_CUSTOM_MODEL_NAME is that same
// name, imported from custom-model-injection.ts so the two can never drift apart.
// Verified end-to-end against a real llama-swap server after correcting the
// ORIGINAL recipe, which was wrong (env vars, not a config file — see the
// customModelInjection comment on grok's registry entry).
argv: (prompt) => ['--always-approve', '-m', GROK_CUSTOM_MODEL_NAME, '-p', prompt],
},
deepseek: {
confidence: 'unknown',
note: 'dsh is a profile launcher, not a documented one-shot prompt flag. Best-effort only.',
argv: (prompt) => ['--profile', 'headless', prompt],
},
omp: {
confidence: 'verified',
// --model custom/<id>: same reasoning as pi — omp's own default model has no
// credential, so without an explicit --model it never reaches our injected
// provider at all. Verified end-to-end against a real llama-swap server after
// the same two fixes as pi (array-shaped `models`, HOME-redirect instead of
// PI_CONFIG_DIR — omp hardcodes `~/.omp/agent/models.yml`).
argv: (prompt, modelId) => ['--model', `custom/${modelId}`, '-p', prompt],
},
};
// --- baseline server check ---------------------------------------------------
async function baselineCheck(
baseUrl: string,
apiKey: string,
authStyle: AuthStyle,
model: string | null,
prompt: string,
timeoutMs: number
): Promise<string> {
console.log(`\n=== Step 0: baseline check against ${baseUrl} (auth: ${authStyle}) ===`);
// Exactly ONE header, never both. An earlier version sent both auth conventions
// (Bearer + api-key) on the theory that an unused header is harmless — live-
// tested against a real llama-swap server, sending both reliably HUNG the
// request indefinitely (reproduced 3x: Bearer alone ~500ms, api-key alone
// ~600ms, both together no response inside a 15s timeout). Use --auth-style
// api-key for endpoints that specifically want that header (e.g. Azure AI
// Foundry); default 'bearer' covers everything else.
const authHeaders: Record<string, string> =
authStyle === 'api-key' ? { 'api-key': apiKey } : { Authorization: `Bearer ${apiKey}` };
let discoveredModel = model;
try {
const res = await fetch(`${baseUrl}/v1/models`, {
headers: authHeaders,
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { data?: Array<{ id: string }> };
const ids: string[] = (body.data ?? []).map((m) => m.id);
console.log(`GET /v1/models -> ${ids.length ? ids.join(', ') : '(empty list)'}`);
if (!discoveredModel && ids.length) discoveredModel = ids[0];
} catch (err) {
console.error(`${TAG} GET /v1/models failed: ${(err as Error).message}`);
console.error(`${TAG} Is the server actually running at ${baseUrl}? Aborting.`);
process.exit(1);
}
if (!discoveredModel) {
console.error(`${TAG} No --model given and none discovered from /v1/models. Aborting.`);
process.exit(1);
}
// Live-tested against a real llama-swap server: a POST issued right after a GET on
// the same Node process reliably HANGS indefinitely (reproduced repeatedly — GET
// alone ~30ms, POST alone ~1-2s, GET-then-immediate-POST times out completely; a
// 2s pause between them fixed it every time). This looks like Node's fetch (undici)
// reusing a pooled keep-alive connection the server doesn't handle cleanly for a
// second request right behind a first. A short pause is the simplest portable fix
// (no extra deps, no need for undici's Agent/dispatcher API).
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({
model: discoveredModel,
messages: [{ role: 'user', content: prompt }],
}),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const body = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
const reply: string = body.choices?.[0]?.message?.content ?? '';
if (!reply.trim()) throw new Error('empty reply');
console.log(`POST /v1/chat/completions -> "${reply.trim().slice(0, 200)}"`);
console.log('Server baseline: PASS\n');
} catch (err) {
console.error(`${TAG} POST /v1/chat/completions failed: ${(err as Error).message}`);
console.error(`${TAG} Server responded to /v1/models but not to a chat request. Aborting.`);
process.exit(1);
}
return discoveredModel;
}
// --- per-harness run ----------------------------------------------------------
interface ChildResult {
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
}
function runChild(bin: string, argv: string[], env: Record<string, string>, searchDirs: string[], timeoutMs: number) {
return new Promise<ChildResult>((resolve) => {
let stdout = '';
let stderr = '';
let settled = false;
const child = spawn(bin, argv, {
env: { ...process.env, ...env, PATH: pathWithExtraDirs(searchDirs) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({ code: null, stdout, stderr, timedOut: true });
}, timeoutMs);
child.stdout.on('data', (d) => (stdout += d.toString()));
child.stderr.on('data', (d) => (stderr += d.toString()));
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ code: null, stdout, stderr: `${stderr}\n${err.message}`, timedOut: false });
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ code, stdout, stderr, timedOut: false });
});
});
}
interface HarnessResult {
id: string;
confidence: Confidence | 'unsupported' | 'no-one-shot-recipe';
status: 'PASS' | 'FAIL' | 'UNCONFIRMED' | 'SKIP' | 'LIST';
detail: string;
}
async function runHarness(
entry: CliEntry,
opts: Opts,
model: string,
endpoint: CustomModelEndpoint
): Promise<HarnessResult> {
const id = entry.id;
const injectionCap = entry.capabilities.customModelInjection;
// Dynamic: driven by the REGISTRY's own capability, not a hardcoded id check.
// A future CLI declared unsupported is skipped automatically, same as antigravity today.
if (injectionCap.kind === 'unsupported') {
return {
id,
confidence: 'unsupported',
status: 'SKIP',
detail: 'no known custom-model mechanism (registry: unsupported)',
};
}
const oneShot = ONE_SHOT[id];
if (!oneShot) {
return {
id,
confidence: 'no-one-shot-recipe',
status: 'SKIP',
detail:
'registry supports custom-model injection for this CLI, but this script has no ONE_SHOT invocation entry yet — add one to test it',
};
}
const binary = entry.discovery.binaries[0] ?? id;
const searchDirs = entry.discovery.searchDirs;
const resolved = resolveBinary(binary, searchDirs);
if (!resolved) {
return {
id,
confidence: oneShot.confidence,
status: 'SKIP',
detail: `binary "${binary}" not found on PATH or search dirs`,
};
}
// The REAL injection logic — same function the production route calls.
const injection = buildCustomModelInjection(entry, endpoint, model);
let env: Record<string, string> = {};
let tempDir: string | null = null;
if (injection.kind === 'env') {
env = injection.envOverrides;
} else if (injection.kind === 'configDir') {
tempDir = mkdtempSync(join(tmpdir(), `codeman-local-llm-test-${id}-`));
env = applyConfigDirInjection(tempDir, injection);
}
// injection.kind === 'unsupported' already handled via injectionCap above.
const argv = oneShot.argv(opts.prompt, model);
if (opts.list) {
const detail = `${binary} ${argv.join(' ')} | env: ${Object.keys(env).join(', ')}${tempDir ? ` | configDir: ${tempDir}` : ''}`;
if (tempDir && !opts.keepTemp) rmSync(tempDir, { recursive: true, force: true });
return { id, confidence: oneShot.confidence, status: 'LIST', detail };
}
const { code, stdout, stderr, timedOut } = await runChild(binary, argv, env, searchDirs, opts.timeout);
let detailSuffix = '';
if (tempDir && !opts.keepTemp) rmSync(tempDir, { recursive: true, force: true });
else if (tempDir) detailSuffix = ` [config kept at ${tempDir}]`;
if (timedOut) {
return {
id,
confidence: oneShot.confidence,
status: 'FAIL',
detail: `timed out after ${opts.timeout}ms. stderr: ${stderr.slice(-300)}${detailSuffix}`,
};
}
const reply = stdout.trim();
const matched = /hello/i.test(reply) && /world/i.test(reply);
const softStatus: HarnessResult['status'] = oneShot.confidence === 'verified' ? 'FAIL' : 'UNCONFIRMED';
if (code !== 0) {
return {
id,
confidence: oneShot.confidence,
status: softStatus,
detail: `exit ${code}. stderr: ${stderr.trim().slice(-300) || '(empty)'}${detailSuffix}`,
};
}
if (!reply) {
return { id, confidence: oneShot.confidence, status: softStatus, detail: `exit 0 but empty stdout${detailSuffix}` };
}
if (matched) {
return { id, confidence: oneShot.confidence, status: 'PASS', detail: `${reply.slice(0, 200)}${detailSuffix}` };
}
return {
id,
confidence: oneShot.confidence,
status: 'UNCONFIRMED',
detail: `reply didn't match heuristic, judge by eye: "${reply.slice(0, 300)}"${detailSuffix}`,
};
}
// --- main ---------------------------------------------------------------------
async function main(): Promise<void> {
const configDefaults = loadConfigFile();
const opts = parseArgs(process.argv.slice(2), configDefaults);
if (opts.help) {
printUsage();
process.exit(0);
}
// Dynamic: pulled from the live registry, not a hardcoded id list. `kind === 'agent'`
// excludes 'shell' (no model/endpoint concept). Antigravity stays in this list (it IS
// an enabled agent CLI) — it's the `unsupported` capability check in runHarness that
// skips it, not an exclusion here.
const allEntries = enabledClis().filter((e) => e.kind === 'agent');
const byId = new Map<string, CliEntry>(allEntries.map((e) => [e.id as string, e]));
const ids: string[] = opts.only ?? [...byId.keys()];
const unknownIds = ids.filter((id) => !byId.has(id));
if (unknownIds.length) {
console.error(`${TAG} unknown harness id(s): ${unknownIds.join(', ')}`);
console.error(`${TAG} known ids (from the live CLI registry): ${[...byId.keys()].join(', ')}`);
process.exit(1);
}
const entries = ids.map((id) => byId.get(id)!);
// --probe-help never touches the network — no --base-url needed for it.
if (opts.probeHelp) {
for (const entry of entries) {
const binary = entry.discovery.binaries[0] ?? entry.id;
const resolved = resolveBinary(binary, entry.discovery.searchDirs);
console.log(`\n=== ${entry.id} (${binary}) ===`);
if (!resolved) {
console.log('(not found on PATH or search dirs)');
continue;
}
printHelp(binary, entry.discovery.searchDirs);
}
process.exit(0);
}
if (!opts.baseUrl) {
console.error(`${TAG} --base-url is required (pass it, or set "baseUrl" in ${CONFIG_PATH}).`);
console.error(`${TAG} See ${CONFIG_EXAMPLE_PATH} for the config file shape.\n`);
printUsage();
process.exit(1);
}
opts.baseUrl = opts.baseUrl.replace(/\/+$/, '');
const endpoint: CustomModelEndpoint = {
id: 'standalone-test',
label: 'standalone test',
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
};
// --list is a pure dry run: never touch the network, even if --model was given.
let model: string;
if (opts.list) {
model = opts.model ?? 'local-model';
console.log(`\n=== Step 0 skipped (--list never hits the network; using placeholder "${model}") ===\n`);
} else {
model = await baselineCheck(opts.baseUrl, opts.apiKey, opts.authStyle, opts.model, opts.prompt, opts.timeout);
}
console.log(`=== Testing ${entries.length} harness(es) ===`);
const results: HarnessResult[] = [];
for (const entry of entries) {
process.stdout.write(`\n--- ${entry.id} ---\n`);
const result = await runHarness(entry, opts, model, endpoint);
results.push(result);
console.log(`${result.status}: ${result.detail}`);
}
console.log('\n=== Summary ===');
const width = Math.max(...results.map((r) => r.id.length)) + 2;
for (const r of results) {
console.log(`${r.id.padEnd(width)} [${r.confidence.padEnd(20)}] ${r.status.padEnd(11)} ${r.detail.slice(0, 100)}`);
}
const hardFail = results.some((r) => r.status === 'FAIL' && r.confidence === 'verified');
if (hardFail) {
console.error(
`\n${TAG} at least one VERIFIED harness FAILed — that's a real regression, not just an unconfirmed guess.`
);
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error(`${TAG} unexpected error:`, err);
process.exit(1);
});