mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat+test+docs: D64-D67 — olp Node CLI + doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 (#42)
* feat+test+docs: D64+D65+D66+D67 — olp Node CLI + olp doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 Second substantive Phase 4 implementation. 4 D-days bundled per Iron Rule 11 IDR — CLI dispatches to doctor; doctor calls into provider plugins via the new contract method; ADR amendment authorizes the contract change. Single PR is the minimum reviewable unit for "does plugin amendment + plugin impl + doctor consumer line up?" ## D64 — bin/olp.mjs Node CLI scaffold Operator surface for OLP. Node not bash (per ADR 0010 § Notes — bash with python3 JSON parsing is a known fragile point; OLP standardizes on Node). Subcommands: - status / health / usage / models / cache — HTTP calls to existing endpoints - providers — local: cross-references models-registry.json + config.json - chain show [<model>] — local: prints routing.chains from ~/.olp/config.json - logs [N] [--level X] — reads ~/.olp/logs/audit.ndjson via audit-query - restart — launchctl (macOS) / systemctl --user (Linux), best-effort - keys ... — delegates to bin/olp-keys.mjs runCli (no logic duplicated) - doctor [--check <id|category>] [--json] — D65 framework - help / --help / -h Token / URL resolution: - OLP_PROXY_URL env → OLP_PORT env → http://127.0.0.1:4567 (D60 default) - OLP_API_KEY env → OLP_OWNER_TOKEN env (filesystem manifest tokens are one-way SHA-256 per ADR 0007 § 5 — not recoverable; CLI surfaces helpful 401 message pointing at olp-keys keygen) Output: - Default: human-readable ANSI-colored text (no chalk dep, auto-suppressed under --json) - --json: raw JSON for scripting - Exit codes: 0=ok / 1=usage / 2=network|HTTP / 3=auth No npm deps. Built-ins only. Installed via package.json bin entry so `npx olp <subcommand>` works. ## D65 — lib/doctor.mjs framework Ports OCP scripts/doctor.mjs (the bedrock of AI-driven self-repair per the OCP audit's #2 inheritance candidate). Machine-readable next_action so a Claude Code / Cursor / etc. agent can self-repair OLP. Check shape: { id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } } Built-in checks: server.running, server.version, config.exists, config.providers_enabled, config.chains_configured, auth.owner_key_exists, system.node_version. Per-provider checks collected dynamically via provider.doctorChecks() per D67. --json output: { checks: [...], kind: noop|update|fix_oauth|fix_config|fresh_install| fix_server|fix_provider, next_action: { ai_executable: [], human_required: [], verify: 'olp doctor' }, summary } --check <id-or-category> for tight repair-loop fast paths. ## D66 — Per-provider doctorChecks() implementations Each shipped plugin contributes its own checks (lives in plugin file so the provider's maintainer updates it naturally): - anthropic.mjs: cli_available (claude --version) + oauth_token_present (~/.claude/.credentials.json OR ANTHROPIC_OAUTH_TOKEN env) - codex.mjs: cli_available (codex --version) + auth_present (~/.codex/config.json) - mistral.mjs: cli_available (vibe --version) + api_key_present (MISTRAL_API_KEY env OR ~/.vibe/.env) Each fail returns evidence.fix_commands (for ai_executable[]) or evidence.human_required (e.g., 'run: claude auth login'). ## D67 — ADR 0002 Amendment 7 New amendment adds OPTIONAL provider.doctorChecks(): DoctorCheck[] to the Provider contract. Backwards compatible — plugins without doctorChecks() contribute no provider checks (default behavior). Validator extended in lib/providers/base.mjs validateProvider. ## Test count 636 → 658 (+22 tests across Suites 32, 33). - Suite 32 — bin/olp.mjs CLI scaffold (10 tests): parseArgv, USAGE, unknown-subcommand, providers local + --json, chain show, status via ephemeral server with owner token, ECONNREFUSED → exit 2, resolveBearerToken precedence - Suite 33 — lib/doctor.mjs framework (12 tests): all kind branches (noop / fresh_install / fix_server / fix_oauth / fix_provider), collectProviderChecks reads doctorChecks(), throwing plugin captured, --check filter, built-in checks against temp HOME, anthropic plugin probe set, resolveProxyUrl precedence, deriveKind/deriveNextAction units ## Scope discipline server.mjs UNTOUCHED. All HTTP subcommands consume EXISTING endpoints. No new endpoints. No /health.anonymousKey. No olp-connect. No Telegram plugin. No IDE docs bundle. No CHANGELOG / package.json version bump (Phase 4 close handles versioning; only package.json bin entries updated). ## Known limitations (flagged for reviewer) - olp restart not unit-tested (would require mocking child_process.spawn in invasive way; manual smoke-test only at this D-day) - olp logs --level filtering matches optional level field if present in audit-event objects; appendAuditEvent already populates it where meaningful — no schema change needed in this bundle - olp usage panel shape inferred from lib/audit-query.mjs exports; if /v0/management/dashboard-data wire shape differs in subtle ways, formatter degrades to '?' but --json always works ## Authority - ADR 0010 § Phase 4 D-day plan D64-D67 line - ADR 0002 Amendment 7 (this commit — new amendment) - OCP ocp bash wrapper /Users/taodeng/ocp/ocp (subcommand reference, translated to Node) - OCP scripts/doctor.mjs /Users/taodeng/ocp/scripts/doctor.mjs (framework reference) - 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 2: olp doctor machine-readable next_action) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D64-D67 reviewer P2 fold-in — shell-quote ai_executable paths + launchctl kickstart caveat Reviewer APPROVE — 0 P0/P1, 2 P2 hardening notes folded in. P2-1 — Shell-quote interpolated paths in fix_commands. lib/doctor.mjs config.exists fix_commands previously interpolated ${olpHome} / ${configPath} unquoted into the printf template. A malicious OLP_HOME env value containing shell metacharacters could inject commands into the suggested-fix string an AI agent (or human) pastes back into a terminal. Added _shellQuote(s) helper (POSIX single-quote-wrap with escape for embedded single quotes per POSIX shell rules). Risk surface is narrow at family scale (operator local env, single-user proxy), but hardening cost is one helper. P2-2 — Document launchctl kickstart -k env-stale pitfall. cmdRestart header now carries an explicit caveat that `launchctl kickstart -k` does NOT re-read the plist EnvironmentVariables block — launchd uses cached env from the most recent bootstrap. This is a known OCP institutional lesson (PIT INDEX in cc-rules MEMORY.md). The comment documents the bootout/bootstrap dance for env reloads and notes that the Phase 4 installer (post-D73) will expose `olp restart --full` for the safer reload path. 658/658 tests still pass; the _shellQuote change is invisible to existing tests because the test fixtures use safe paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Executable
+734
@@ -0,0 +1,734 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* bin/olp.mjs — OLP operator CLI (Phase 4 / D64)
|
||||
*
|
||||
* Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper
|
||||
* (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the
|
||||
* python3 JSON-parsing fragility called out in the ADR.
|
||||
*
|
||||
* Subcommands:
|
||||
* status GET /v0/management/status (owner-only)
|
||||
* health GET /health
|
||||
* usage GET /v0/management/dashboard-data (owner-only)
|
||||
* models GET /v1/models
|
||||
* cache GET /cache/stats (owner-only)
|
||||
* providers local: models-registry + config providers.enabled
|
||||
* chain show [<model>] local: ~/.olp/config.json routing.chains
|
||||
* logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query
|
||||
* restart launchctl (macOS) / systemctl --user (Linux)
|
||||
* doctor [--check X] run lib/doctor.mjs runDoctor + format
|
||||
* keys ... delegate to bin/olp-keys.mjs
|
||||
* help | --help usage
|
||||
*
|
||||
* Global flags:
|
||||
* --json emit raw JSON (silences human-readable output)
|
||||
* --proxy-url=<url> override resolved proxy URL
|
||||
* --olp-home=<path> override ~/.olp
|
||||
*
|
||||
* URL resolution:
|
||||
* OLP_PROXY_URL env (full URL) → http://127.0.0.1:${OLP_PORT || 4567}
|
||||
*
|
||||
* Auth (Bearer token) resolution:
|
||||
* 1. OLP_API_KEY env
|
||||
* 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4)
|
||||
* 3. Most recently used active owner-tier key from listKeys() ← plaintext NOT recoverable from disk
|
||||
*
|
||||
* Note: the third option only works during the same session in which `olp-keys
|
||||
* keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN.
|
||||
* listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore
|
||||
* reports "no owner token available" + remediation instructions if env vars are
|
||||
* absent — it does NOT try to crack the hash.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = success
|
||||
* 1 = bad usage / unknown subcommand
|
||||
* 2 = network or HTTP error (4xx/5xx)
|
||||
* 3 = auth missing / forbidden
|
||||
*/
|
||||
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { spawn as spawnProc } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { realpathSync } from 'node:fs';
|
||||
|
||||
import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs';
|
||||
import { listKeys } from '../lib/keys.mjs';
|
||||
import { readAuditWindow } from '../lib/audit-query.mjs';
|
||||
import modelsRegistry from '../models-registry.json' with { type: 'json' };
|
||||
import { runCli as runKeysCli } from './olp-keys.mjs';
|
||||
|
||||
// ── ANSI helpers (no chalk dep) ───────────────────────────────────────────
|
||||
|
||||
const ANSI = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
gray: '\x1b[90m',
|
||||
};
|
||||
|
||||
function colorize(s, code, useColor) {
|
||||
if (!useColor) return s;
|
||||
return `${code}${s}${ANSI.reset}`;
|
||||
}
|
||||
|
||||
function statusBadge(status, useColor) {
|
||||
const map = {
|
||||
ok: { txt: 'PASS', col: ANSI.green },
|
||||
warn: { txt: 'WARN', col: ANSI.yellow },
|
||||
fail: { txt: 'FAIL', col: ANSI.red },
|
||||
};
|
||||
const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray };
|
||||
return colorize(`[${m.txt}]`, m.col, useColor);
|
||||
}
|
||||
|
||||
// ── Arg parser (mirror bin/olp-keys.mjs shape) ────────────────────────────
|
||||
|
||||
export function parseArgv(argv) {
|
||||
const positional = [];
|
||||
const flags = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const eq = a.indexOf('=');
|
||||
if (eq > 0) {
|
||||
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
||||
} else {
|
||||
const name = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
flags[name] = next;
|
||||
i++;
|
||||
} else {
|
||||
flags[name] = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
return { positional, flags };
|
||||
}
|
||||
|
||||
// ── Output helpers (respect --json) ───────────────────────────────────────
|
||||
|
||||
function makeIO(opts) {
|
||||
const out = opts.out ?? (s => process.stdout.write(s));
|
||||
const err = opts.err ?? (s => process.stderr.write(s));
|
||||
const wantJson = opts.json === true;
|
||||
// When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op
|
||||
// (debug noise suppression per the bundle requirements). `errln` always writes
|
||||
// to stderr.
|
||||
const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); };
|
||||
const errln = (...parts) => err(parts.join(' ') + '\n');
|
||||
const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n');
|
||||
return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) };
|
||||
}
|
||||
|
||||
// ── HTTP helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
let urlObj;
|
||||
try { urlObj = new URL(url); }
|
||||
catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); }
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const reqFn = isHttps ? httpsRequest : httpRequest;
|
||||
let req;
|
||||
try {
|
||||
req = reqFn(url, { method, headers, timeout: timeoutMs }, res => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
|
||||
});
|
||||
} catch (e) {
|
||||
return finish({ ok: false, error: String(e?.message ?? e) });
|
||||
}
|
||||
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code }));
|
||||
req.on('timeout', () => {
|
||||
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Token resolution ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a Bearer token. Returns the plaintext token string or null.
|
||||
* Precedence: OLP_API_KEY → OLP_OWNER_TOKEN → null (manifests are one-way hashed).
|
||||
*/
|
||||
export function resolveBearerToken() {
|
||||
if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY;
|
||||
if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN;
|
||||
return null;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const tok = resolveBearerToken();
|
||||
return tok ? { Authorization: `Bearer ${tok}` } : {};
|
||||
}
|
||||
|
||||
// ── HTTP-error → exit code mapping ────────────────────────────────────────
|
||||
|
||||
function httpErrorToExit(res, io) {
|
||||
if (!res.ok) {
|
||||
if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) {
|
||||
io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`);
|
||||
io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`);
|
||||
return 2;
|
||||
}
|
||||
io.errln(`Error: network error: ${res.error}`);
|
||||
return 2;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`);
|
||||
io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`);
|
||||
return 3;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`);
|
||||
return 3;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Human-readable formatters ─────────────────────────────────────────────
|
||||
|
||||
function formatBytes(n) {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||
if (n < 1024) return `${n}B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
||||
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
// ── Subcommand: status ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdStatus(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize('OLP status', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
io.log(` version: ${body.version}`);
|
||||
io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`);
|
||||
io.log(` started: ${body.started_at}`);
|
||||
io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`);
|
||||
if (body.providers?.status && typeof body.providers.status === 'object') {
|
||||
for (const [name, s] of Object.entries(body.providers.status)) {
|
||||
const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor);
|
||||
io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`);
|
||||
}
|
||||
}
|
||||
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
|
||||
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
|
||||
if (body.stats?.cache) {
|
||||
const c = body.stats.cache;
|
||||
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.entries ?? '?'}`);
|
||||
}
|
||||
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
||||
io.log(` recent errors: ${body.recent_errors.length}`);
|
||||
for (const e of body.recent_errors.slice(0, 5)) {
|
||||
io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'} — ${(e.message ?? '').slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: health ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdHealth(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/health`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
io.log(` ${k}:`);
|
||||
for (const [k2, v2] of Object.entries(v)) {
|
||||
io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`);
|
||||
}
|
||||
} else {
|
||||
io.log(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: usage ─────────────────────────────────────────────────────
|
||||
|
||||
async function cmdUsage(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
const u24 = body.usage_24h ?? body.usage24h ?? body['24h'] ?? {};
|
||||
if (typeof u24 === 'object' && Object.keys(u24).length > 0) {
|
||||
io.log(` requests: ${u24.requests ?? '?'}`);
|
||||
io.log(` cache hits: ${u24.cache_hits ?? '?'}`);
|
||||
io.log(` fallbacks: ${u24.fallbacks ?? '?'}`);
|
||||
} else {
|
||||
io.log(' (no 24h usage data — server may not have processed any requests yet)');
|
||||
}
|
||||
if (Array.isArray(body.providers)) {
|
||||
io.log('');
|
||||
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const p of body.providers) {
|
||||
io.log(` ${String(p.name ?? '?').padEnd(12)} ${p.percent_used != null ? `${p.percent_used}% used` : 'no quota api'}`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(body.top_fallback_chains)) {
|
||||
io.log('');
|
||||
io.log(colorize('Top fallback chains', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const f of body.top_fallback_chains.slice(0, 10)) {
|
||||
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: models ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdModels(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/v1/models`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize(`OLP models (${(body.data ?? []).length})`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const m of body.data ?? []) {
|
||||
io.log(` ${m.id.padEnd(35)} ${colorize(`(${m.owned_by ?? '?'})`, ANSI.gray, io.useColor)}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: cache ─────────────────────────────────────────────────────
|
||||
|
||||
async function cmdCache(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/cache/stats`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize('OLP cache', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
io.log(` entries: ${body.entries ?? '?'}`);
|
||||
io.log(` hits: ${body.hits ?? 0}`);
|
||||
io.log(` misses: ${body.misses ?? 0}`);
|
||||
io.log(` evictions:${body.evictions ?? 0}`);
|
||||
io.log(` bytes: ${formatBytes(body.bytes ?? 0)} (max ${formatBytes(body.maxBytes ?? 0)})`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: providers (local) ─────────────────────────────────────────
|
||||
|
||||
function cmdProviders(flags, io) {
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
let enabled = {};
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
enabled = cfg?.providers?.enabled ?? {};
|
||||
} catch { /* fine — empty enabled map */ }
|
||||
|
||||
const providers = modelsRegistry?.providers ?? {};
|
||||
const rows = [];
|
||||
for (const [name, p] of Object.entries(providers)) {
|
||||
rows.push({
|
||||
name,
|
||||
displayName: p?.displayName ?? name,
|
||||
tier: p?.tier ?? '?',
|
||||
modelCount: (p?.models ?? []).length,
|
||||
enabled: enabled[name] === true,
|
||||
candidate: p?.candidate === true,
|
||||
});
|
||||
}
|
||||
if (io.wantJson) {
|
||||
io.emitJson({ providers: rows, config_path: configPath });
|
||||
return 0;
|
||||
}
|
||||
io.log(colorize(`OLP providers (${rows.length} in registry)`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const r of rows) {
|
||||
const enabledTxt = r.enabled
|
||||
? colorize('enabled ', ANSI.green, io.useColor)
|
||||
: colorize('disabled', ANSI.gray, io.useColor);
|
||||
const candTxt = r.candidate ? colorize('(candidate)', ANSI.yellow, io.useColor) : '';
|
||||
io.log(` ${r.name.padEnd(10)} ${enabledTxt} tier ${r.tier} models ${String(r.modelCount).padStart(2)} ${candTxt}`);
|
||||
}
|
||||
io.log('');
|
||||
io.log(colorize(`config: ${configPath}`, ANSI.dim, io.useColor));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: chain show ────────────────────────────────────────────────
|
||||
|
||||
function cmdChainShow(positional, flags, io) {
|
||||
const target = positional[0] ?? null; // model name, or null = print all
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
let chains = {};
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
chains = cfg?.routing?.chains ?? {};
|
||||
} catch { /* empty */ }
|
||||
|
||||
if (io.wantJson) {
|
||||
if (target) {
|
||||
io.emitJson({ model: target, chain: chains[target] ?? null });
|
||||
} else {
|
||||
io.emitJson({ chains });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
io.log(colorize('OLP routing.chains', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
if (Object.keys(chains).length === 0) {
|
||||
io.log(` (no chains configured in ${configPath})`);
|
||||
return 0;
|
||||
}
|
||||
if (target) {
|
||||
const chain = chains[target];
|
||||
if (!chain) {
|
||||
io.errln(`Error: model "${target}" not in routing.chains (configured: ${Object.keys(chains).join(', ')}).`);
|
||||
return 1;
|
||||
}
|
||||
io.log(` ${target}:`);
|
||||
for (const hop of chain) {
|
||||
io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
|
||||
}
|
||||
} else {
|
||||
for (const [model, chain] of Object.entries(chains)) {
|
||||
io.log(` ${model}:`);
|
||||
for (const hop of chain) {
|
||||
io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: logs ──────────────────────────────────────────────────────
|
||||
|
||||
async function cmdLogs(positional, flags, io) {
|
||||
const n = positional[0] ? parseInt(positional[0], 10) : 20;
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
io.errln(`Error: invalid log count "${positional[0]}"`);
|
||||
return 1;
|
||||
}
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
// readAuditWindow is a generator over [startMs, endMs). Default window = last 24h.
|
||||
const windowMs = flags['window-ms'] ? parseInt(flags['window-ms'], 10) : 24 * 3600 * 1000;
|
||||
const endMs = Date.now();
|
||||
const startMs = endMs - windowMs;
|
||||
let events = [];
|
||||
try {
|
||||
for (const ev of readAuditWindow({ startMs, endMs, olpHome })) {
|
||||
events.push(ev);
|
||||
}
|
||||
} catch (e) {
|
||||
io.errln(`Error: readAuditWindow failed: ${e?.message ?? e}`);
|
||||
return 2;
|
||||
}
|
||||
let filtered = events;
|
||||
if (flags.level) {
|
||||
filtered = filtered.filter(e => e.level === flags.level);
|
||||
}
|
||||
// Tail (audit events are already chronological per generator order).
|
||||
filtered = filtered.slice(-n);
|
||||
if (io.wantJson) {
|
||||
io.emitJson({ events: filtered });
|
||||
return 0;
|
||||
}
|
||||
io.log(colorize(`OLP logs (last ${filtered.length} of ${events.length}${flags.level ? `, level=${flags.level}` : ''})`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const e of filtered) {
|
||||
// Audit event shape per lib/audit.mjs: { ts, event, ...data }. `level` is
|
||||
// not always present in audit ndjson (it is in stderr-side logEvent).
|
||||
const level = (e.level ?? 'info').toUpperCase();
|
||||
const levelColor =
|
||||
level === 'ERROR' ? ANSI.red
|
||||
: level === 'WARN' ? ANSI.yellow
|
||||
: ANSI.gray;
|
||||
const summary = e.message ?? e.error ?? '';
|
||||
io.log(` ${colorize(level.padEnd(5), levelColor, io.useColor)} ${e.ts ?? '?'} ${e.event ?? '?'} ${summary}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: restart ───────────────────────────────────────────────────
|
||||
|
||||
async function cmdRestart(flags, io) {
|
||||
// macOS: launchctl kickstart -k gui/$(id -u)/dev.olp.proxy
|
||||
// Linux: systemctl --user restart olp-proxy
|
||||
// Neither installed → fall through to a helpful error.
|
||||
//
|
||||
// CAVEAT (D64-D67 reviewer P2-2; known OCP institutional lesson per
|
||||
// ~/.cc-rules/memory/auto/MEMORY.md PIT INDEX): `launchctl kickstart -k`
|
||||
// does NOT re-read the plist's EnvironmentVariables block — launchd
|
||||
// sticks to its cached env from the most recent bootstrap. If you edited
|
||||
// ~/Library/LaunchAgents/dev.olp.proxy.plist's env, this subcommand will
|
||||
// silently use stale values. Use `launchctl bootout gui/<uid>/dev.olp.proxy`
|
||||
// followed by `launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.olp.proxy.plist`
|
||||
// to force a clean env reload. The Phase 4 installer (planned post-D73)
|
||||
// will expose `olp restart --full` for the bootout/bootstrap dance.
|
||||
const platform = process.platform;
|
||||
const uid = process.getuid?.() ?? null;
|
||||
let cmd, args;
|
||||
if (platform === 'darwin') {
|
||||
if (uid == null) {
|
||||
io.errln('Error: cannot resolve UID on this platform; cannot drive launchctl');
|
||||
return 2;
|
||||
}
|
||||
cmd = 'launchctl';
|
||||
args = ['kickstart', '-k', `gui/${uid}/dev.olp.proxy`];
|
||||
} else if (platform === 'linux') {
|
||||
cmd = 'systemctl';
|
||||
args = ['--user', 'restart', 'olp-proxy'];
|
||||
} else {
|
||||
io.errln(`Error: platform "${platform}" not supported for 'olp restart'.`);
|
||||
io.errln(`Hint: run 'npm start' (or whatever launches your OLP server) manually.`);
|
||||
return 2;
|
||||
}
|
||||
// Spawn + wait for exit; bubble up any error.
|
||||
const result = await new Promise(resolve => {
|
||||
const child = spawnProc(cmd, args, { stdio: io.wantJson ? 'ignore' : 'inherit' });
|
||||
child.on('error', e => resolve({ code: -1, error: e }));
|
||||
child.on('exit', code => resolve({ code }));
|
||||
});
|
||||
if (result.code === 0) {
|
||||
if (io.wantJson) {
|
||||
io.emitJson({ ok: true, cmd, args });
|
||||
} else {
|
||||
io.log(colorize(`Restart issued (${cmd} ${args.join(' ')})`, ANSI.green, io.useColor));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (result.error?.code === 'ENOENT' || result.code === 127) {
|
||||
io.errln(`Error: '${cmd}' not found on this system.`);
|
||||
if (platform === 'darwin') {
|
||||
io.errln(`Hint: no launchd service 'dev.olp.proxy' installed; run 'npm start' manually.`);
|
||||
} else {
|
||||
io.errln(`Hint: no systemd user unit 'olp-proxy' installed; run 'npm start' manually.`);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
io.errln(`Error: ${cmd} ${args.join(' ')} exited with code ${result.code}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ── Subcommand: doctor ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdDoctor(flags, io) {
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
|
||||
|
||||
const result = await runDoctor({
|
||||
olpHome,
|
||||
proxyUrl,
|
||||
checkFilter,
|
||||
});
|
||||
|
||||
if (io.wantJson) {
|
||||
io.emitJson(result);
|
||||
return result.fail_count === 0 ? 0 : 2;
|
||||
}
|
||||
|
||||
io.log(colorize(`OLP doctor — ${result.summary}`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const c of result.checks) {
|
||||
io.log(` ${statusBadge(c.status, io.useColor)} ${c.id.padEnd(36)} ${c.message}`);
|
||||
}
|
||||
io.log('');
|
||||
io.log(` fail=${result.fail_count} warn=${result.warn_count} ok=${result.ok_count} kind=${result.kind}`);
|
||||
if (result.next_action.ai_executable.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Next (AI-executable):', ANSI.cyan, io.useColor));
|
||||
for (const cmd of result.next_action.ai_executable) io.log(` $ ${cmd}`);
|
||||
}
|
||||
if (result.next_action.human_required.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Next (human-required):', ANSI.yellow, io.useColor));
|
||||
for (const step of result.next_action.human_required) io.log(` • ${step}`);
|
||||
}
|
||||
io.log('');
|
||||
io.log(colorize(`verify: ${result.next_action.verify}`, ANSI.dim, io.useColor));
|
||||
return result.fail_count === 0 ? 0 : 2;
|
||||
}
|
||||
|
||||
// ── Subcommand: keys (delegate) ───────────────────────────────────────────
|
||||
|
||||
async function cmdKeys(rest, io) {
|
||||
// Re-use bin/olp-keys.mjs's runCli. Its `out`/`err` writers receive raw strings
|
||||
// (no \n needed since the underlying CLI emits them itself).
|
||||
return await runKeysCli(rest, {
|
||||
out: s => process.stdout.write(s),
|
||||
err: s => process.stderr.write(s),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Usage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const USAGE = `OLP operator CLI — Phase 4 (ADR 0010)
|
||||
|
||||
Usage:
|
||||
olp <subcommand> [args] [--json] [--proxy-url=<url>] [--olp-home=<path>]
|
||||
|
||||
Subcommands:
|
||||
status GET /v0/management/status (owner-only)
|
||||
health GET /health
|
||||
usage GET /v0/management/dashboard-data (owner-only)
|
||||
models GET /v1/models
|
||||
cache GET /cache/stats (owner-only)
|
||||
providers list providers (registry + config providers.enabled)
|
||||
chain show [<model>] print routing.chains from ~/.olp/config.json
|
||||
logs [N] [--level X] last N audit events from ~/.olp/logs/audit.ndjson
|
||||
restart launchctl (macOS) / systemctl --user (Linux)
|
||||
doctor [--check X] run diagnostic checks (id, category, or prefix filter)
|
||||
keys [args ...] delegate to bin/olp-keys.mjs
|
||||
help print this message
|
||||
|
||||
Global flags:
|
||||
--json emit raw JSON (silences human-readable formatting)
|
||||
--proxy-url=<url> override resolved proxy URL
|
||||
--olp-home=<path> override ~/.olp
|
||||
|
||||
Env:
|
||||
OLP_PROXY_URL full URL (overrides OLP_PORT)
|
||||
OLP_PORT port for default URL (default: 4567)
|
||||
OLP_API_KEY Bearer token for the proxy
|
||||
OLP_OWNER_TOKEN synthetic env-owner token (ADR 0007 § 9.4)
|
||||
OLP_HOME ~/.olp override
|
||||
|
||||
Exit codes:
|
||||
0 success
|
||||
1 bad usage / unknown subcommand
|
||||
2 network or HTTP error (4xx/5xx)
|
||||
3 auth missing / forbidden`;
|
||||
|
||||
// ── runCli (testable entry) ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the CLI with explicit argv + IO streams. Returns the exit code (no
|
||||
* process.exit). Exported for tests.
|
||||
*
|
||||
* @param {string[]} argv args after the script name
|
||||
* @param {object} [opts]
|
||||
* @param {(s: string) => void} [opts.out]
|
||||
* @param {(s: string) => void} [opts.err]
|
||||
* @param {boolean} [opts.useColor] default true; tests pass false for deterministic strings
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runCli(argv, opts = {}) {
|
||||
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
|
||||
const io = makeIO({ ...opts, json: false });
|
||||
io.log(USAGE);
|
||||
return argv.length === 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = argv;
|
||||
|
||||
// `olp keys ...` passes the remaining argv straight to bin/olp-keys.mjs.
|
||||
if (subcommand === 'keys') {
|
||||
const io = makeIO({ ...opts, json: false });
|
||||
return await cmdKeys(rest, io);
|
||||
}
|
||||
|
||||
const { positional, flags } = parseArgv(rest);
|
||||
const json = flags.json === true;
|
||||
const io = makeIO({ ...opts, json });
|
||||
|
||||
switch (subcommand) {
|
||||
case 'status': return await cmdStatus(flags, io);
|
||||
case 'health': return await cmdHealth(flags, io);
|
||||
case 'usage': return await cmdUsage(flags, io);
|
||||
case 'models': return await cmdModels(flags, io);
|
||||
case 'cache': return await cmdCache(flags, io);
|
||||
case 'providers': return cmdProviders(flags, io);
|
||||
case 'chain': {
|
||||
// `olp chain show [model]`
|
||||
const sub = positional[0];
|
||||
if (sub !== 'show') {
|
||||
io.errln(`Error: unknown 'chain' subcommand "${sub}". Try: olp chain show [model]`);
|
||||
return 1;
|
||||
}
|
||||
return cmdChainShow(positional.slice(1), flags, io);
|
||||
}
|
||||
case 'logs': return await cmdLogs(positional, flags, io);
|
||||
case 'restart': return await cmdRestart(flags, io);
|
||||
case 'doctor': return await cmdDoctor(flags, io);
|
||||
default:
|
||||
io.errln(`Error: unknown subcommand "${subcommand}".`);
|
||||
io.errln(USAGE);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main guard ────────────────────────────────────────────────────────────
|
||||
|
||||
function _isMain() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
if (_isMain()) {
|
||||
runCli(process.argv.slice(2))
|
||||
.then(code => process.exit(code))
|
||||
.catch(e => {
|
||||
process.stderr.write(`Fatal: ${e?.stack ?? e}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,26 @@
|
||||
|
||||
## Amendments
|
||||
|
||||
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
|
||||
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
|
||||
|
||||
### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX)
|
||||
|
||||
- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too.
|
||||
- **Change — add to Provider contract:**
|
||||
- Introduce **OPTIONAL** `doctorChecks()` returning `DoctorCheck[]` where each `DoctorCheck` has the shape:
|
||||
- `id: string` — unique per check, conventionally `<provider>.<probe-name>` (e.g. `anthropic.cli_available`, `anthropic.oauth_token_present`).
|
||||
- `category: 'provider'` — fixed for plugin-contributed checks. The framework reserves `'server'`, `'auth'`, `'config'`, `'system'` for built-in checks.
|
||||
- `async run(): { status: 'ok' | 'fail' | 'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }` — runs the probe. `status: 'fail'` makes `olp doctor` exit non-zero and contributes to the `kind: fix_provider` discriminator; `evidence.fix_commands[]` is concatenated into `next_action.ai_executable[]` and `evidence.human_steps[]` into `next_action.human_required[]`.
|
||||
- **Backwards compatibility:** Plugins that omit `doctorChecks()` contribute zero provider checks. Their healthCheck() return value continues to flow through `/health.providers.status.<name>` exactly as today. No existing plugin behaviour changes; no existing test breaks. `validateProvider` in `lib/providers/base.mjs` is updated to type-check `doctorChecks` only when present (must be a function); absence is allowed.
|
||||
- **What `doctorChecks()` is for vs. what `healthCheck()` is for:**
|
||||
- `healthCheck()` answers "is this provider currently usable?" — checked at the request-execution layer; output feeds `/health` and per-request retry decisions.
|
||||
- `doctorChecks()` answers "if this provider is broken, what specific actionable steps fix it?" — checked at the operator layer; output feeds `olp doctor` + the `next_action.ai_executable[]` repair templates that a downstream AI agent can paste-and-run.
|
||||
- **Suggested probe set (per plugin):**
|
||||
- `<provider>.cli_available` — spawn `<bin> --version` with short timeout (≤3s); fail → fix_commands include install instruction.
|
||||
- `<provider>.<auth-artifact>_present` — check whether the auth file / env var the plugin's `readAuthArtifact()` reads is populated; fail → human_steps include the login command (which usually requires browser interaction and so cannot be in `ai_executable[]`).
|
||||
- **Authority:** ADR 0010 § Phase 4 D64-D67 (this is the addition called out by that charter). No provider CLI doc citation needed — `doctorChecks()` is an internal contract field. Implementation lands in D67 (this PR): `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, `lib/providers/mistral.mjs` each gain a `doctorChecks()` method covering `cli_available` + `<auth-artifact>_present`.
|
||||
- **Tests:** Suite 32 (`bin/olp.mjs` CLI smoke) and Suite 33 (`olp doctor` framework) in `test-features.mjs` cover the contract amendment. Suite 33 specifically asserts: (a) a plugin without `doctorChecks()` contributes no provider checks (default behaviour), (b) a plugin with a failing `doctorChecks()` probe triggers `kind: fix_provider` and propagates its `evidence.fix_commands[]` into `next_action.ai_executable[]`, (c) all-passing checks yield `kind: noop`.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 11 (IDR) — the contract amendment, the plugin implementations, the doctor framework, and the CLI scaffold are tightly coupled. They land as a single PR (D64-D67 bundle) because reviewing them separately cannot verify that consumer + producer line up. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
|
||||
|
||||
### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1)
|
||||
|
||||
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* lib/doctor.mjs — OLP doctor framework (Phase 4 / D65)
|
||||
*
|
||||
* Authority: ADR 0010 § Phase 4 D64-D67 + ADR 0002 Amendment 7 (D67) —
|
||||
* per-provider `doctorChecks()` contract method that this framework consumes.
|
||||
*
|
||||
* `olp doctor` runs a set of `Check` objects. Each check has:
|
||||
* - id: string (unique, e.g. 'server.running', 'anthropic.cli_available')
|
||||
* - category: 'server'|'auth'|'config'|'provider'|'system'
|
||||
* - async run(): { status: 'ok'|'fail'|'warn', message, evidence? }
|
||||
*
|
||||
* Built-in checks (categories server / auth / config / system) are defined in
|
||||
* `buildBuiltinChecks()` below; per-provider checks are sourced from each loaded
|
||||
* plugin's optional `doctorChecks()` method (ADR 0002 Amendment 7).
|
||||
*
|
||||
* Output shape (machine-readable — consumed by `bin/olp.mjs --json`):
|
||||
* {
|
||||
* schema_version: 1,
|
||||
* generated_at: '2026-05-26T...',
|
||||
* checks: [{ id, category, status, message, evidence? }],
|
||||
* fail_count: number,
|
||||
* warn_count: number,
|
||||
* kind: 'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install',
|
||||
* next_action: { ai_executable: string[], human_required: string[], verify: string },
|
||||
* summary: string,
|
||||
* }
|
||||
*
|
||||
* `kind` precedence (highest first — the most upstream blocker wins):
|
||||
* 1. fresh_install — config.exists FAIL (~/.olp/config.json missing/malformed)
|
||||
* 2. fix_server — server.running FAIL
|
||||
* 3. fix_oauth — auth.owner_key_exists FAIL
|
||||
* 4. fix_provider — any provider-category FAIL
|
||||
* 5. fix_config — any other config-category FAIL
|
||||
* 6. noop — all OK (or WARN-only)
|
||||
*
|
||||
* `next_action.ai_executable[]` aggregates `evidence.fix_commands[]` from every
|
||||
* FAIL check; `next_action.human_required[]` aggregates `evidence.human_steps[]`.
|
||||
* `verify` is always `olp doctor` (re-run after applying the fix).
|
||||
*
|
||||
* Design notes:
|
||||
* - Pure functions + dependency injection: callers pass `{ checks }` (which can
|
||||
* be overridden for tests) plus a `{ now }` clock for deterministic timestamps.
|
||||
* - No filesystem writes. No process.exit. No console.log. Callers handle I/O.
|
||||
* - All checks run in parallel via Promise.all — individual check failures are
|
||||
* captured (not propagated) so one broken check does not hide others.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
|
||||
import { loadProviders } from './providers/index.mjs';
|
||||
import { loadFallbackConfigSync } from './fallback/engine.mjs';
|
||||
import { listKeys } from './keys.mjs';
|
||||
|
||||
// ── Schema version ────────────────────────────────────────────────────────
|
||||
|
||||
export const DOCTOR_SCHEMA_VERSION = 1;
|
||||
|
||||
// ── Built-in check builders ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the OLP base URL the CLI / doctor will probe.
|
||||
* Precedence:
|
||||
* 1. opts.proxyUrl (explicit caller override)
|
||||
* 2. OLP_PROXY_URL env (full URL like http://host:port)
|
||||
* 3. http://127.0.0.1:${OLP_PORT || 4567}
|
||||
*/
|
||||
export function resolveProxyUrl(opts = {}) {
|
||||
if (opts.proxyUrl) return String(opts.proxyUrl).replace(/\/+$/, '');
|
||||
if (process.env.OLP_PROXY_URL) return String(process.env.OLP_PROXY_URL).replace(/\/+$/, '');
|
||||
const port = process.env.OLP_PORT ?? '4567';
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OLP_HOME directory (mirrors lib/keys.mjs precedence).
|
||||
* 1. opts.olpHome
|
||||
* 2. OLP_HOME env
|
||||
* 3. ~/.olp
|
||||
*/
|
||||
export function resolveOlpHome(opts = {}) {
|
||||
if (opts.olpHome) return opts.olpHome;
|
||||
if (process.env.OLP_HOME) return process.env.OLP_HOME;
|
||||
return join(homedir(), '.olp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper — issue a GET to the proxy with a tight timeout. Returns
|
||||
* `{ ok: true, status, body }` or `{ ok: false, error }`. Never throws.
|
||||
*/
|
||||
async function httpGet(url, { timeoutMs = 3000, headers = {} } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
let req;
|
||||
try {
|
||||
req = httpRequest(url, { method: 'GET', headers, timeout: timeoutMs }, res => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
|
||||
});
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: String(e?.message ?? e) });
|
||||
return;
|
||||
}
|
||||
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e) }));
|
||||
req.on('timeout', () => {
|
||||
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default check set. Test-mode overrides:
|
||||
* - opts.injectChecks: [...Check] — REPLACES the built-in set entirely
|
||||
* - opts.providersOverride: Map<name, plugin> — REPLACES loaded providers for the per-provider sweep
|
||||
* - opts.skipNetwork: true → omit server.running / server.version (offline mode)
|
||||
* - opts.olpHome: override ~/.olp lookup
|
||||
* - opts.proxyUrl: override resolveProxyUrl()
|
||||
*/
|
||||
/**
|
||||
* D64-D67 reviewer P2-1: shell-quote a path before interpolation into
|
||||
* `ai_executable[]` strings. Single-quote-wrap + escape any embedded single
|
||||
* quote per POSIX shell rules: `foo'bar` → `'foo'\''bar'`. Defends against
|
||||
* a malicious `OLP_HOME` env value injecting shell metacharacters into the
|
||||
* suggested-fix command an AI agent (or human) might paste back.
|
||||
*
|
||||
* Risk surface is narrow at family scale (operator local env, single-user
|
||||
* proxy), but the hardening cost is one helper.
|
||||
*
|
||||
* @param {string} s
|
||||
* @returns {string} single-quoted shell-safe string
|
||||
*/
|
||||
function _shellQuote(s) {
|
||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function buildBuiltinChecks(opts = {}) {
|
||||
if (opts.injectChecks) return opts.injectChecks;
|
||||
|
||||
const olpHome = resolveOlpHome(opts);
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
const proxyUrl = resolveProxyUrl(opts);
|
||||
|
||||
const checks = [];
|
||||
|
||||
// ── system.* ─────────────────────────────────────────────────────────
|
||||
checks.push({
|
||||
id: 'system.node_version',
|
||||
category: 'system',
|
||||
async run() {
|
||||
// package.json engines.node = >=18; check process.versions.node major >= 18
|
||||
const major = parseInt(String(process.versions.node).split('.')[0], 10);
|
||||
if (Number.isFinite(major) && major >= 18) {
|
||||
return { status: 'ok', message: `Node ${process.versions.node} (>=18)` };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `Node ${process.versions.node} is below the required >=18 (per package.json engines.node)`,
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'Install a current Node.js LTS (>=18) — see https://nodejs.org/en/download',
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── config.* ─────────────────────────────────────────────────────────
|
||||
checks.push({
|
||||
id: 'config.exists',
|
||||
category: 'config',
|
||||
async run() {
|
||||
if (!existsSync(configPath)) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${configPath} not found`,
|
||||
evidence: {
|
||||
// D64-D67 reviewer P2-1: shell-quote paths via _shellQuote so a
|
||||
// malicious OLP_HOME env can't inject shell metacharacters into
|
||||
// the suggested-fix command pasted into an AI agent.
|
||||
fix_commands: [
|
||||
`mkdir -p ${_shellQuote(olpHome)}`,
|
||||
`printf '%s\\n' '{"auth":{"allow_anonymous":false,"owner_only_endpoints":["/health"],"fallback_detail_header_policy":"owner_only"},"providers":{"enabled":{}},"routing":{"chains":{},"soft_triggers":{}},"streaming":{"heartbeat_interval_ms":0}}' > ${_shellQuote(configPath)}`,
|
||||
],
|
||||
reference: 'docs/adr/0007-multi-key-auth.md § 3, docs/adr/0004-fallback-engine.md',
|
||||
},
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return { status: 'ok', message: `${configPath} parses` };
|
||||
}
|
||||
return { status: 'fail', message: `${configPath} parses but is not a JSON object` };
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${configPath} unreadable / malformed: ${e?.message ?? e}`,
|
||||
evidence: {
|
||||
human_steps: [
|
||||
`Inspect ${configPath} — fix the JSON syntax error (or delete the file to fall back to the empty default and re-run olp doctor)`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: 'config.providers_enabled',
|
||||
category: 'config',
|
||||
async run() {
|
||||
try {
|
||||
const cfg = loadFallbackConfigSync(configPath);
|
||||
const enabled = cfg.providersEnabled ?? {};
|
||||
const enabledNames = Object.keys(enabled).filter(k => enabled[k] === true);
|
||||
if (enabledNames.length > 0) {
|
||||
return { status: 'ok', message: `${enabledNames.length} provider(s) enabled: ${enabledNames.join(', ')}` };
|
||||
}
|
||||
return {
|
||||
status: 'warn',
|
||||
message: 'No providers enabled in config.json (all /v1/chat/completions requests will 503)',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
`Edit ${configPath} → set providers.enabled.<name> = true for at least one provider (anthropic / openai / mistral)`,
|
||||
],
|
||||
reference: 'docs/adr/0002-plugin-architecture.md § Disable model',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'fail', message: `Could not read providers.enabled from ${configPath}: ${e?.message ?? e}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: 'config.chains_configured',
|
||||
category: 'config',
|
||||
async run() {
|
||||
try {
|
||||
const cfg = loadFallbackConfigSync(configPath);
|
||||
const chains = cfg.chains ?? {};
|
||||
const chainNames = Object.keys(chains);
|
||||
if (chainNames.length > 0) {
|
||||
return { status: 'ok', message: `${chainNames.length} chain(s) configured: ${chainNames.join(', ')}` };
|
||||
}
|
||||
return {
|
||||
status: 'warn',
|
||||
message: 'No routing chains configured (single-hop mode; cross-provider fallback inactive)',
|
||||
evidence: {
|
||||
reference: 'docs/adr/0004-fallback-engine.md § Chain configuration',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'fail', message: `Could not read routing.chains from ${configPath}: ${e?.message ?? e}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── auth.* ───────────────────────────────────────────────────────────
|
||||
checks.push({
|
||||
id: 'auth.owner_key_exists',
|
||||
category: 'auth',
|
||||
async run() {
|
||||
// Per ADR 0007 § 9.4: process.env.OLP_OWNER_TOKEN also satisfies "owner identity".
|
||||
if (process.env.OLP_OWNER_TOKEN) {
|
||||
return { status: 'ok', message: 'OLP_OWNER_TOKEN env var present (synthetic env-owner per ADR 0007 § 9.4)' };
|
||||
}
|
||||
try {
|
||||
const keys = listKeys({ olpHome });
|
||||
const activeOwner = keys.find(k => k.owner_tier === 'owner' && k.revoked_at === null);
|
||||
if (activeOwner) {
|
||||
return { status: 'ok', message: `active owner key found: id=${activeOwner.id} name="${activeOwner.name}"` };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'No active owner-tier key found in ~/.olp/keys/ and OLP_OWNER_TOKEN env unset',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npx olp-keys keygen --owner',
|
||||
],
|
||||
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 (bootstrap & recovery)',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'fail', message: `listKeys failed: ${e?.message ?? e}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── server.* ─────────────────────────────────────────────────────────
|
||||
if (!opts.skipNetwork) {
|
||||
checks.push({
|
||||
id: 'server.running',
|
||||
category: 'server',
|
||||
async run() {
|
||||
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 });
|
||||
if (!r.ok) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${proxyUrl}/health unreachable: ${r.error}`,
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npx olp restart',
|
||||
],
|
||||
reference: 'README.md § Running OLP',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (r.status !== 200) {
|
||||
return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` };
|
||||
}
|
||||
return { status: 'ok', message: `${proxyUrl}/health → 200` };
|
||||
},
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: 'server.version',
|
||||
category: 'server',
|
||||
async run() {
|
||||
// Read local package.json version
|
||||
let localVersion = null;
|
||||
try {
|
||||
// Resolve relative to this file — lib/doctor.mjs → ../package.json
|
||||
// import.meta.url gives a file:// URL; convert and join.
|
||||
const here = new URL('../package.json', import.meta.url);
|
||||
const pkg = JSON.parse(readFileSync(here, 'utf8'));
|
||||
localVersion = pkg.version ?? null;
|
||||
} catch {
|
||||
return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' };
|
||||
}
|
||||
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 });
|
||||
if (!r.ok || r.status !== 200) {
|
||||
return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` };
|
||||
}
|
||||
let serverVersion = null;
|
||||
try {
|
||||
serverVersion = JSON.parse(r.body)?.version ?? null;
|
||||
} catch {
|
||||
return { status: 'warn', message: '/health returned non-JSON; cannot compare version' };
|
||||
}
|
||||
if (!serverVersion) {
|
||||
return { status: 'warn', message: '/health did not include version; cannot compare' };
|
||||
}
|
||||
if (serverVersion === localVersion) {
|
||||
return { status: 'ok', message: `local v${localVersion} matches running v${serverVersion}` };
|
||||
}
|
||||
return {
|
||||
status: 'warn',
|
||||
message: `local v${localVersion} differs from running v${serverVersion} — restart to pick up the new code`,
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npx olp restart',
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep loaded providers for doctorChecks() (ADR 0002 Amendment 7).
|
||||
* Plugins without doctorChecks() contribute nothing (default — back-compat).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Map} [opts.providersOverride] — Map<name, plugin> for tests
|
||||
* @param {object} [opts.providersEnabled] — Record<string, boolean>; default = all from config.json
|
||||
* @returns {Check[]}
|
||||
*/
|
||||
export function collectProviderChecks(opts = {}) {
|
||||
let providers;
|
||||
if (opts.providersOverride) {
|
||||
providers = opts.providersOverride;
|
||||
} else {
|
||||
const olpHome = resolveOlpHome(opts);
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
let enabled = opts.providersEnabled;
|
||||
if (!enabled) {
|
||||
try {
|
||||
enabled = loadFallbackConfigSync(configPath).providersEnabled ?? {};
|
||||
} catch {
|
||||
enabled = {};
|
||||
}
|
||||
}
|
||||
providers = loadProviders({ enabled });
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
for (const [_name, plugin] of providers) {
|
||||
if (typeof plugin?.doctorChecks !== 'function') continue;
|
||||
let pluginChecks;
|
||||
try {
|
||||
pluginChecks = plugin.doctorChecks();
|
||||
} catch (e) {
|
||||
// Misbehaving plugin — surface as a synthesized fail check, do not crash.
|
||||
checks.push({
|
||||
id: `${plugin.name}.doctor_checks_threw`,
|
||||
category: 'provider',
|
||||
async run() {
|
||||
return { status: 'fail', message: `doctorChecks() threw: ${e?.message ?? e}` };
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(pluginChecks)) continue;
|
||||
for (const c of pluginChecks) {
|
||||
if (c && typeof c.id === 'string' && typeof c.run === 'function') {
|
||||
checks.push({
|
||||
id: c.id,
|
||||
category: c.category ?? 'provider',
|
||||
run: c.run,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
// ── Discriminator (kind precedence) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a flat results array, determine the next-action discriminator.
|
||||
* Per ADR 0010 § D65 framework:
|
||||
* fresh_install > fix_server > fix_oauth > fix_provider > fix_config > noop
|
||||
*/
|
||||
export function deriveKind(results) {
|
||||
const failed = results.filter(r => r.status === 'fail');
|
||||
if (failed.length === 0) return 'noop';
|
||||
|
||||
if (failed.some(r => r.id === 'config.exists')) return 'fresh_install';
|
||||
if (failed.some(r => r.category === 'server')) return 'fix_server';
|
||||
if (failed.some(r => r.category === 'auth')) return 'fix_oauth';
|
||||
if (failed.some(r => r.category === 'provider')) return 'fix_provider';
|
||||
if (failed.some(r => r.category === 'config')) return 'fix_config';
|
||||
return 'fix_config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the next_action block from FAIL results' evidence.
|
||||
*/
|
||||
export function deriveNextAction(results, kind) {
|
||||
const ai_executable = [];
|
||||
const human_required = [];
|
||||
for (const r of results) {
|
||||
if (r.status !== 'fail') continue;
|
||||
const ev = r.evidence ?? {};
|
||||
if (Array.isArray(ev.fix_commands)) ai_executable.push(...ev.fix_commands);
|
||||
if (Array.isArray(ev.human_steps)) human_required.push(...ev.human_steps);
|
||||
}
|
||||
return {
|
||||
ai_executable,
|
||||
human_required,
|
||||
verify: kind === 'noop' ? 'already healthy' : 'olp doctor',
|
||||
};
|
||||
}
|
||||
|
||||
// ── runDoctor (main entry) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute every check in parallel; aggregate; derive kind + next_action.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {Check[]} [opts.injectChecks] — REPLACE the built-in + provider checks entirely
|
||||
* @param {Check[]} [opts.extraChecks] — APPEND extra checks (after defaults)
|
||||
* @param {string} [opts.checkFilter] — restrict to checks whose id OR category matches
|
||||
* @param {Map} [opts.providersOverride] — for the per-provider sweep
|
||||
* @param {object} [opts.providersEnabled] — Record<string, boolean>
|
||||
* @param {string} [opts.olpHome] — override ~/.olp
|
||||
* @param {string} [opts.proxyUrl] — override the proxy URL
|
||||
* @param {boolean} [opts.skipNetwork] — omit server.* checks
|
||||
* @param {() => Date} [opts.now] — clock injection
|
||||
* @returns {Promise<DoctorResult>}
|
||||
*/
|
||||
export async function runDoctor(opts = {}) {
|
||||
const now = opts.now ?? (() => new Date());
|
||||
|
||||
let checks;
|
||||
if (opts.injectChecks) {
|
||||
checks = [...opts.injectChecks];
|
||||
} else {
|
||||
checks = [
|
||||
...buildBuiltinChecks(opts),
|
||||
...collectProviderChecks(opts),
|
||||
];
|
||||
}
|
||||
if (opts.extraChecks) checks.push(...opts.extraChecks);
|
||||
|
||||
// --check <filter>: restrict to checks whose id OR category startsWith / equals the filter.
|
||||
// Match rule: exact id match, exact category match, OR id startsWith `<filter>.`
|
||||
// (so --check anthropic matches both anthropic.cli_available and anthropic.oauth_token_present).
|
||||
if (opts.checkFilter) {
|
||||
const f = String(opts.checkFilter);
|
||||
checks = checks.filter(c =>
|
||||
c.id === f
|
||||
|| c.category === f
|
||||
|| c.id.startsWith(`${f}.`)
|
||||
);
|
||||
}
|
||||
|
||||
// Run all checks in parallel. Capture per-check failures (do not let one throw
|
||||
// hide the rest of the diagnostic).
|
||||
const results = await Promise.all(checks.map(async c => {
|
||||
try {
|
||||
const r = await c.run();
|
||||
return {
|
||||
id: c.id,
|
||||
category: c.category,
|
||||
status: r?.status ?? 'fail',
|
||||
message: r?.message ?? '(check returned no message)',
|
||||
...(r?.evidence !== undefined ? { evidence: r.evidence } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: c.id,
|
||||
category: c.category,
|
||||
status: 'fail',
|
||||
message: `check threw: ${e?.message ?? e}`,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const fail_count = results.filter(r => r.status === 'fail').length;
|
||||
const warn_count = results.filter(r => r.status === 'warn').length;
|
||||
const ok_count = results.filter(r => r.status === 'ok').length;
|
||||
const kind = deriveKind(results);
|
||||
const next_action = deriveNextAction(results, kind);
|
||||
|
||||
let summary;
|
||||
if (fail_count === 0 && warn_count === 0) {
|
||||
summary = `all ${ok_count} checks ok`;
|
||||
} else if (fail_count === 0) {
|
||||
summary = `${ok_count} ok, ${warn_count} warn — no FAIL; kind=${kind}`;
|
||||
} else {
|
||||
const firstFail = results.find(r => r.status === 'fail');
|
||||
summary = `${fail_count} of ${results.length} checks failed — ${firstFail?.id ?? '?'} (${firstFail?.message?.slice(0, 80) ?? ''})`;
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: DOCTOR_SCHEMA_VERSION,
|
||||
generated_at: now().toISOString(),
|
||||
checks: results,
|
||||
fail_count,
|
||||
warn_count,
|
||||
ok_count,
|
||||
kind,
|
||||
next_action,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Check
|
||||
* @property {string} id
|
||||
* @property {'server'|'auth'|'config'|'provider'|'system'} category
|
||||
* @property {() => Promise<{ status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }>} run
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DoctorResult
|
||||
* @property {number} schema_version
|
||||
* @property {string} generated_at (ISO timestamp)
|
||||
* @property {Array<{ id: string, category: string, status: 'ok'|'fail'|'warn', message: string, evidence?: object }>} checks
|
||||
* @property {number} fail_count
|
||||
* @property {number} warn_count
|
||||
* @property {number} ok_count
|
||||
* @property {'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install'} kind
|
||||
* @property {{ ai_executable: string[], human_required: string[], verify: string }} next_action
|
||||
* @property {string} summary
|
||||
*/
|
||||
@@ -485,6 +485,64 @@ function _defaultBinaryExists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||
// Per-plugin probe templates consumed by `olp doctor`. Each check returns
|
||||
// { status, message, evidence? } where evidence.fix_commands[] flow into the
|
||||
// next_action.ai_executable[] block and evidence.human_steps[] into
|
||||
// next_action.human_required[].
|
||||
//
|
||||
// Probes:
|
||||
// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN)
|
||||
// anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken
|
||||
//
|
||||
// Both probes share the existing test seams (binaryExists / readAuthArtifact) so the
|
||||
// suite can stub them deterministically without spawning the real binary.
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
const authRead = _authReadFn ?? readAuthArtifact;
|
||||
return [
|
||||
{
|
||||
id: 'anthropic.cli_available',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
if (binaryExists()) {
|
||||
return { status: 'ok', message: '`claude` binary resolved on PATH' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: '`claude` binary not found on PATH (and OLP_CLAUDE_BIN unset/invalid)',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npm install -g @anthropic-ai/claude-code',
|
||||
],
|
||||
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'anthropic.oauth_token_present',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
const auth = authRead();
|
||||
if (auth?.accessToken) {
|
||||
return { status: 'ok', message: 'OAuth credential present (.credentials.json / env / keychain)' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'Anthropic OAuth credential missing — none of ANTHROPIC_OAUTH_TOKEN env, ~/.claude/.credentials.json, or login keychain returned a token',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'run: claude (the first interactive launch prompts for browser OAuth login)',
|
||||
],
|
||||
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4
|
||||
// contractVersion fold-in per reviewer F3.
|
||||
@@ -509,6 +567,8 @@ const anthropic = {
|
||||
estimateCost,
|
||||
quotaStatus,
|
||||
healthCheck,
|
||||
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||
doctorChecks: () => doctorChecks(),
|
||||
hints: {
|
||||
requiresTTY: false,
|
||||
concurrentSpawnSafe: true,
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
* collectAllChunks directly. ADR 0002 Amendment 3 (D23).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DoctorCheck
|
||||
* @property {string} id - unique per check, e.g. 'anthropic.cli_available'
|
||||
* @property {'provider'} category - fixed for plugin-contributed checks (ADR 0002 Amendment 7)
|
||||
* @property {function} run - async () => { status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProviderContractV1
|
||||
* @property {string} name - unique lowercase key
|
||||
@@ -41,6 +48,7 @@
|
||||
* @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null
|
||||
* @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null
|
||||
* @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string}
|
||||
* @property {function} [doctorChecks] - OPTIONAL () => DoctorCheck[] (ADR 0002 Amendment 7, D67)
|
||||
* @property {ProviderHints} hints
|
||||
*/
|
||||
|
||||
@@ -113,6 +121,13 @@ export function validateProvider(p) {
|
||||
errors.push('healthCheck must be a function');
|
||||
}
|
||||
|
||||
// ADR 0002 Amendment 7 (D67): doctorChecks() is optional. When present it must be
|
||||
// a function; absence is allowed (plugin contributes no provider-tier checks to
|
||||
// `olp doctor` — built-in server/system/auth checks still run).
|
||||
if (p.doctorChecks !== undefined && typeof p.doctorChecks !== 'function') {
|
||||
errors.push('doctorChecks must be a function or omitted');
|
||||
}
|
||||
|
||||
if (!p.hints || typeof p.hints !== 'object') {
|
||||
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }');
|
||||
} else {
|
||||
|
||||
@@ -637,6 +637,59 @@ function _defaultBinaryExists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
|
||||
//
|
||||
// Probes:
|
||||
// openai.cli_available — `codex --version` resolves on PATH (or via OLP_CODEX_BIN)
|
||||
// openai.auth_present — `readAuthArtifact()` returns a non-empty accessToken
|
||||
// (Codex CLI reference § Authentication: credentials in $CODEX_HOME, default ~/.codex/auth.json)
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
const authRead = _authReadFn ?? readAuthArtifact;
|
||||
return [
|
||||
{
|
||||
id: 'openai.cli_available',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
if (binaryExists()) {
|
||||
return { status: 'ok', message: '`codex` binary resolved on PATH' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: '`codex` binary not found on PATH (and OLP_CODEX_BIN unset/invalid)',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npm install -g @openai/codex',
|
||||
],
|
||||
reference: 'https://developers.openai.com/codex/cli/reference',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'openai.auth_present',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
const auth = authRead();
|
||||
if (auth?.accessToken) {
|
||||
return { status: 'ok', message: 'Codex auth artifact present ($CODEX_HOME/auth.json)' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'Codex auth artifact missing — $CODEX_HOME/auth.json does not contain access_token (default $CODEX_HOME=~/.codex)',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'run: codex (the first interactive launch prompts for OAuth login per Codex CLI reference § Authentication)',
|
||||
],
|
||||
reference: 'https://developers.openai.com/codex/cli/reference',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||
|
||||
@@ -662,6 +715,8 @@ const codex = {
|
||||
estimateCost,
|
||||
quotaStatus,
|
||||
healthCheck,
|
||||
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||
doctorChecks: () => doctorChecks(),
|
||||
hints: {
|
||||
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
|
||||
@@ -764,6 +764,60 @@ function _defaultBinaryExists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
|
||||
//
|
||||
// Probes:
|
||||
// mistral.cli_available — `vibe --version` resolves on PATH (or via OLP_VIBE_BIN)
|
||||
// mistral.api_key_present — readAuthArtifact() returns apiKey (MISTRAL_API_KEY env or ~/.vibe/.env)
|
||||
// (DOCS-2: https://docs.mistral.ai/mistral-vibe/terminal/configuration —
|
||||
// auth from MISTRAL_API_KEY env / ~/.vibe/.env)
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
const authRead = _authReadFn ?? readAuthArtifact;
|
||||
return [
|
||||
{
|
||||
id: 'mistral.cli_available',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
if (binaryExists()) {
|
||||
return { status: 'ok', message: '`vibe` binary resolved on PATH' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: '`vibe` binary not found on PATH (and OLP_VIBE_BIN unset/invalid)',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npm install -g @mistralai/vibe',
|
||||
],
|
||||
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/quickstart',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mistral.api_key_present',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
const auth = authRead();
|
||||
if (auth?.apiKey) {
|
||||
return { status: 'ok', message: 'Mistral API key present (env MISTRAL_API_KEY or ~/.vibe/.env)' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'Mistral API key missing — neither MISTRAL_API_KEY env nor ~/.vibe/.env (or $VIBE_HOME/.env) supplied a key',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'export MISTRAL_API_KEY=<your-key> # or write MISTRAL_API_KEY=... into ~/.vibe/.env',
|
||||
],
|
||||
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/configuration',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||
|
||||
@@ -795,6 +849,8 @@ const mistral = {
|
||||
estimateCost,
|
||||
quotaStatus,
|
||||
healthCheck,
|
||||
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||
doctorChecks: () => doctorChecks(),
|
||||
hints: {
|
||||
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
"bin": {
|
||||
"olp": "./bin/olp.mjs",
|
||||
"olp-keys": "./bin/olp-keys.mjs",
|
||||
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node test-features.mjs",
|
||||
"olp": "node bin/olp.mjs",
|
||||
"olp-keys": "node bin/olp-keys.mjs",
|
||||
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs"
|
||||
},
|
||||
|
||||
@@ -13591,3 +13591,467 @@ describe('Suite 31 — D63 /v0/management/status (ADR 0010 § Phase 4 D61-D63)',
|
||||
`total_requests expected >= 1, got ${body.stats.total_requests}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 32: D64 bin/olp.mjs CLI scaffold (ADR 0010 § Phase 4 D64-D67) ───
|
||||
//
|
||||
// Smoke tests for the operator CLI. The CLI is invoked in-process via its
|
||||
// exported runCli() so we get exit codes without spawning a subprocess. The
|
||||
// process.env is mutated per-test (saved + restored in before/after) so the
|
||||
// runtime resolution paths (OLP_PROXY_URL / OLP_PORT / OLP_API_KEY / OLP_HOME)
|
||||
// behave deterministically.
|
||||
//
|
||||
// The CLI sources are:
|
||||
// bin/olp.mjs — runCli(argv, { out, err, useColor }) → exit code
|
||||
// lib/doctor.mjs — runDoctor() consumed by `olp doctor`
|
||||
//
|
||||
// These tests do NOT spawn a real OLP server for the HTTP subcommands; instead
|
||||
// each HTTP test starts an ephemeral createOlpServer() listening on port 0 and
|
||||
// points the CLI at that port via --proxy-url. The owner_token used is created
|
||||
// per-test via createKey({ owner_tier: 'owner' }).
|
||||
|
||||
import { runCli as runOlpCli, parseArgv as parseOlpArgv, resolveBearerToken as olpResolveBearerToken } from './bin/olp.mjs';
|
||||
|
||||
describe('Suite 32 — D64 bin/olp.mjs CLI scaffold (ADR 0010 § Phase 4 D64-D67)', () => {
|
||||
const SAVED_ENV_32 = {
|
||||
OLP_HOME: process.env.OLP_HOME,
|
||||
OLP_PROXY_URL: process.env.OLP_PROXY_URL,
|
||||
OLP_PORT: process.env.OLP_PORT,
|
||||
OLP_API_KEY: process.env.OLP_API_KEY,
|
||||
OLP_OWNER_TOKEN: process.env.OLP_OWNER_TOKEN,
|
||||
};
|
||||
let TMP32;
|
||||
|
||||
before(() => {
|
||||
TMP32 = mkdtempSync(pathJoin(tmpdir(), 'olp-test-32-'));
|
||||
process.env.OLP_HOME = TMP32;
|
||||
// Clear conflicting env vars for deterministic resolution
|
||||
delete process.env.OLP_PROXY_URL;
|
||||
delete process.env.OLP_PORT;
|
||||
delete process.env.OLP_API_KEY;
|
||||
delete process.env.OLP_OWNER_TOKEN;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const [k, v] of Object.entries(SAVED_ENV_32)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
rmSync(TMP32, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper: capture stdout / stderr buffers via IO writers
|
||||
function makeCapture() {
|
||||
let out = '', err = '';
|
||||
return {
|
||||
out: s => { out += s; },
|
||||
err: s => { err += s; },
|
||||
get stdout() { return out; },
|
||||
get stderr() { return err; },
|
||||
};
|
||||
}
|
||||
|
||||
it('32a — parseArgv: --flag=value + --flag value + bare --flag', () => {
|
||||
const a = parseOlpArgv(['--json', '--port=4567', '--name', 'foo', 'positional']);
|
||||
assert.equal(a.flags.json, true);
|
||||
assert.equal(a.flags.port, '4567');
|
||||
assert.equal(a.flags.name, 'foo');
|
||||
assert.deepEqual(a.positional, ['positional']);
|
||||
});
|
||||
|
||||
it('32b — `olp` (no args) prints USAGE and returns exit code 1', async () => {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli([], { out: cap.out, err: cap.err, useColor: false });
|
||||
assert.equal(code, 1, 'no-args should return 1');
|
||||
assert.match(cap.stdout, /OLP operator CLI/);
|
||||
assert.match(cap.stdout, /Subcommands:/);
|
||||
});
|
||||
|
||||
it('32c — `olp help` prints USAGE and returns exit code 0', async () => {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(['help'], { out: cap.out, err: cap.err, useColor: false });
|
||||
assert.equal(code, 0);
|
||||
assert.match(cap.stdout, /OLP operator CLI/);
|
||||
});
|
||||
|
||||
it('32d — `olp unknown` prints error + USAGE on stderr, returns exit code 1', async () => {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(['frobnicate'], { out: cap.out, err: cap.err, useColor: false });
|
||||
assert.equal(code, 1);
|
||||
assert.match(cap.stderr, /unknown subcommand "frobnicate"/);
|
||||
assert.match(cap.stderr, /OLP operator CLI/);
|
||||
});
|
||||
|
||||
it('32e — `olp providers` (local, no HTTP) → lists registry entries', async () => {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(['providers'], { out: cap.out, err: cap.err, useColor: false });
|
||||
assert.equal(code, 0);
|
||||
// All three shipped plugin keys must appear
|
||||
assert.match(cap.stdout, /anthropic/);
|
||||
assert.match(cap.stdout, /openai/);
|
||||
assert.match(cap.stdout, /mistral/);
|
||||
// No providers enabled in the empty TMP32 config → all show disabled
|
||||
assert.match(cap.stdout, /disabled/);
|
||||
});
|
||||
|
||||
it('32f — `olp providers --json` emits parseable JSON with providers[]', async () => {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(['providers', '--json'], { out: cap.out, err: cap.err, useColor: false });
|
||||
assert.equal(code, 0);
|
||||
const body = JSON.parse(cap.stdout);
|
||||
assert.ok(Array.isArray(body.providers));
|
||||
assert.ok(body.providers.length >= 3);
|
||||
const names = body.providers.map(p => p.name);
|
||||
assert.ok(names.includes('anthropic'));
|
||||
assert.ok(names.includes('openai'));
|
||||
assert.ok(names.includes('mistral'));
|
||||
});
|
||||
|
||||
it('32g — `olp chain show` with no chains in config prints "no chains configured"', async () => {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(['chain', 'show'], { out: cap.out, err: cap.err, useColor: false });
|
||||
assert.equal(code, 0);
|
||||
assert.match(cap.stdout, /no chains configured/);
|
||||
});
|
||||
|
||||
it('32h — `olp status` against an ephemeral OLP server with owner token → 200', async () => {
|
||||
// Spin up an ephemeral server bound to a random port; CLI talks to it.
|
||||
__setProvidersEnabled({});
|
||||
__setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' });
|
||||
const mod = await import('./server.mjs');
|
||||
mod.__clearCache();
|
||||
mod.__clearRecentErrors();
|
||||
mod.__resetRequestCounters();
|
||||
|
||||
const server32 = mod.createOlpServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server32.listen(0, '127.0.0.1', resolve);
|
||||
server32.once('error', reject);
|
||||
});
|
||||
const port32 = server32.address().port;
|
||||
try {
|
||||
// Create owner key + use it via OLP_API_KEY env (resolveBearerToken reads it first)
|
||||
const { plaintext_token } = createKey({
|
||||
name: '32h-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP32,
|
||||
});
|
||||
const SAVED_TOK = process.env.OLP_API_KEY;
|
||||
process.env.OLP_API_KEY = plaintext_token;
|
||||
try {
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(
|
||||
['status', `--proxy-url=http://127.0.0.1:${port32}`, '--json'],
|
||||
{ out: cap.out, err: cap.err, useColor: false },
|
||||
);
|
||||
assert.equal(code, 0, `status JSON exit non-zero; stderr=${cap.stderr}`);
|
||||
const body = JSON.parse(cap.stdout);
|
||||
assert.equal(body.ok, true);
|
||||
assert.ok(typeof body.version === 'string');
|
||||
assert.ok(typeof body.uptime_ms === 'number');
|
||||
} finally {
|
||||
if (SAVED_TOK === undefined) delete process.env.OLP_API_KEY;
|
||||
else process.env.OLP_API_KEY = SAVED_TOK;
|
||||
}
|
||||
} finally {
|
||||
await new Promise(r => server32.close(r));
|
||||
__resetProvidersEnabled();
|
||||
__setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' });
|
||||
}
|
||||
});
|
||||
|
||||
it('32i — `olp status` against a non-existent port → exit 2 ECONNREFUSED', async () => {
|
||||
// Pick a random high port unlikely to be in use; the CLI must detect
|
||||
// ECONNREFUSED and surface the helpful hint.
|
||||
const cap = makeCapture();
|
||||
const code = await runOlpCli(
|
||||
['status', '--proxy-url=http://127.0.0.1:1'], // port 1 → ECONNREFUSED on macOS/Linux
|
||||
{ out: cap.out, err: cap.err, useColor: false },
|
||||
);
|
||||
assert.equal(code, 2, `expected ECONNREFUSED → exit 2, got ${code}; stderr=${cap.stderr}`);
|
||||
assert.match(cap.stderr, /unreachable|ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it('32j — resolveBearerToken precedence: OLP_API_KEY > OLP_OWNER_TOKEN > null', () => {
|
||||
const SAVED_API = process.env.OLP_API_KEY;
|
||||
const SAVED_OWN = process.env.OLP_OWNER_TOKEN;
|
||||
try {
|
||||
// 1. Neither set → null
|
||||
delete process.env.OLP_API_KEY;
|
||||
delete process.env.OLP_OWNER_TOKEN;
|
||||
assert.equal(olpResolveBearerToken(), null);
|
||||
|
||||
// 2. Only OLP_OWNER_TOKEN → that wins
|
||||
process.env.OLP_OWNER_TOKEN = 'owner-tok-32j';
|
||||
assert.equal(olpResolveBearerToken(), 'owner-tok-32j');
|
||||
|
||||
// 3. OLP_API_KEY also set → API key wins
|
||||
process.env.OLP_API_KEY = 'api-key-32j';
|
||||
assert.equal(olpResolveBearerToken(), 'api-key-32j');
|
||||
} finally {
|
||||
if (SAVED_API === undefined) delete process.env.OLP_API_KEY;
|
||||
else process.env.OLP_API_KEY = SAVED_API;
|
||||
if (SAVED_OWN === undefined) delete process.env.OLP_OWNER_TOKEN;
|
||||
else process.env.OLP_OWNER_TOKEN = SAVED_OWN;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 33: D65 lib/doctor.mjs framework (ADR 0010 § Phase 4 D64-D67) ───
|
||||
//
|
||||
// Framework tests for `olp doctor`. Uses runDoctor()'s opts.injectChecks
|
||||
// hook to drive every kind branch (noop / fresh_install / fix_server /
|
||||
// fix_oauth / fix_provider / fix_config) deterministically without depending
|
||||
// on real filesystem or network state.
|
||||
|
||||
import {
|
||||
runDoctor as runOlpDoctor,
|
||||
buildBuiltinChecks as buildOlpBuiltinChecks,
|
||||
collectProviderChecks as collectOlpProviderChecks,
|
||||
deriveKind as deriveOlpKind,
|
||||
deriveNextAction as deriveOlpNextAction,
|
||||
resolveProxyUrl as resolveOlpProxyUrl,
|
||||
DOCTOR_SCHEMA_VERSION,
|
||||
} from './lib/doctor.mjs';
|
||||
|
||||
describe('Suite 33 — D65 lib/doctor.mjs framework (ADR 0010 § Phase 4 D64-D67)', () => {
|
||||
|
||||
// Helper: build a fake check with deterministic status + optional evidence
|
||||
function fakeCheck(id, category, status, message = 'fake', evidence) {
|
||||
return {
|
||||
id,
|
||||
category,
|
||||
async run() {
|
||||
return evidence ? { status, message, evidence } : { status, message };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('33a — all-ok checks → kind=noop, next_action.ai_executable=[]', async () => {
|
||||
const result = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('system.node_version', 'system', 'ok', 'Node 20'),
|
||||
fakeCheck('server.running', 'server', 'ok', 'live'),
|
||||
fakeCheck('auth.owner_key_exists', 'auth', 'ok', 'present'),
|
||||
],
|
||||
});
|
||||
assert.equal(result.schema_version, DOCTOR_SCHEMA_VERSION);
|
||||
assert.equal(result.fail_count, 0);
|
||||
assert.equal(result.kind, 'noop');
|
||||
assert.deepEqual(result.next_action.ai_executable, []);
|
||||
assert.deepEqual(result.next_action.human_required, []);
|
||||
assert.equal(result.next_action.verify, 'already healthy');
|
||||
assert.match(result.summary, /all 3 checks ok/);
|
||||
});
|
||||
|
||||
it('33b — server.running FAIL → kind=fix_server + ai_executable propagates', async () => {
|
||||
const result = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('server.running', 'server', 'fail', 'unreachable', {
|
||||
fix_commands: ['olp restart'],
|
||||
}),
|
||||
fakeCheck('auth.owner_key_exists', 'auth', 'ok', 'present'),
|
||||
],
|
||||
});
|
||||
assert.equal(result.fail_count, 1);
|
||||
assert.equal(result.kind, 'fix_server');
|
||||
assert.deepEqual(result.next_action.ai_executable, ['olp restart']);
|
||||
assert.equal(result.next_action.verify, 'olp doctor');
|
||||
});
|
||||
|
||||
it('33c — auth.owner_key_exists FAIL (no server fail) → kind=fix_oauth', async () => {
|
||||
const result = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('server.running', 'server', 'ok', 'live'),
|
||||
fakeCheck('auth.owner_key_exists', 'auth', 'fail', 'no owner', {
|
||||
fix_commands: ['npx olp-keys keygen --owner'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(result.kind, 'fix_oauth');
|
||||
assert.deepEqual(result.next_action.ai_executable, ['npx olp-keys keygen --owner']);
|
||||
});
|
||||
|
||||
it('33d — config.exists FAIL beats everything else → kind=fresh_install', async () => {
|
||||
const result = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('config.exists', 'config', 'fail', 'missing', {
|
||||
fix_commands: ['mkdir -p ~/.olp'],
|
||||
}),
|
||||
fakeCheck('server.running', 'server', 'fail', 'unreachable', {
|
||||
fix_commands: ['olp restart'],
|
||||
}),
|
||||
fakeCheck('auth.owner_key_exists', 'auth', 'fail', 'no owner'),
|
||||
],
|
||||
});
|
||||
assert.equal(result.kind, 'fresh_install');
|
||||
// ai_executable concatenates all FAIL evidence.fix_commands
|
||||
assert.ok(result.next_action.ai_executable.includes('mkdir -p ~/.olp'));
|
||||
assert.ok(result.next_action.ai_executable.includes('olp restart'));
|
||||
});
|
||||
|
||||
it('33e — provider-category FAIL only → kind=fix_provider, human_steps propagate', async () => {
|
||||
const result = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('server.running', 'server', 'ok', 'live'),
|
||||
fakeCheck('auth.owner_key_exists', 'auth', 'ok', 'present'),
|
||||
fakeCheck('anthropic.oauth_token_present', 'provider', 'fail', 'missing token', {
|
||||
human_steps: ['run: claude (browser OAuth)'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(result.kind, 'fix_provider');
|
||||
assert.deepEqual(result.next_action.ai_executable, []);
|
||||
assert.deepEqual(result.next_action.human_required, ['run: claude (browser OAuth)']);
|
||||
});
|
||||
|
||||
it('33f — collectProviderChecks reads plugin doctorChecks() (ADR 0002 Amendment 7)', () => {
|
||||
// Synthetic plugin map that includes one plugin WITH doctorChecks() + one
|
||||
// plugin WITHOUT (default behaviour — back-compat — contributes nothing).
|
||||
const providers = new Map();
|
||||
providers.set('alpha', {
|
||||
name: 'alpha',
|
||||
doctorChecks() {
|
||||
return [
|
||||
{ id: 'alpha.cli_available', category: 'provider', async run() { return { status: 'ok', message: 'alpha bin ok' }; } },
|
||||
{ id: 'alpha.auth_present', category: 'provider', async run() { return { status: 'fail', message: 'alpha missing' }; } },
|
||||
];
|
||||
},
|
||||
});
|
||||
providers.set('legacy', {
|
||||
name: 'legacy',
|
||||
// No doctorChecks() — plugin pre-amendment 7; collectProviderChecks ignores it
|
||||
});
|
||||
const checks = collectOlpProviderChecks({ providersOverride: providers });
|
||||
const ids = checks.map(c => c.id).sort();
|
||||
assert.deepEqual(ids, ['alpha.auth_present', 'alpha.cli_available']);
|
||||
// Categories normalized to 'provider'
|
||||
assert.ok(checks.every(c => c.category === 'provider'));
|
||||
});
|
||||
|
||||
it('33g — plugin doctorChecks() that throws is captured (does not crash framework)', async () => {
|
||||
const providers = new Map();
|
||||
providers.set('bad', {
|
||||
name: 'bad',
|
||||
doctorChecks() { throw new Error('boom from bad plugin'); },
|
||||
});
|
||||
const checks = collectOlpProviderChecks({ providersOverride: providers });
|
||||
assert.equal(checks.length, 1);
|
||||
assert.equal(checks[0].id, 'bad.doctor_checks_threw');
|
||||
// Run it — should resolve to fail without re-throwing
|
||||
const r = await checks[0].run();
|
||||
assert.equal(r.status, 'fail');
|
||||
assert.match(r.message, /boom from bad plugin/);
|
||||
});
|
||||
|
||||
it('33h — --check filter restricts to matching id/category/prefix', async () => {
|
||||
// Filter by category
|
||||
const r1 = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('a.x', 'provider', 'ok'),
|
||||
fakeCheck('b.y', 'system', 'ok'),
|
||||
],
|
||||
checkFilter: 'system',
|
||||
});
|
||||
assert.equal(r1.checks.length, 1);
|
||||
assert.equal(r1.checks[0].id, 'b.y');
|
||||
|
||||
// Filter by id prefix
|
||||
const r2 = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('anthropic.cli_available', 'provider', 'ok'),
|
||||
fakeCheck('anthropic.oauth_token_present', 'provider', 'ok'),
|
||||
fakeCheck('openai.cli_available', 'provider', 'ok'),
|
||||
],
|
||||
checkFilter: 'anthropic',
|
||||
});
|
||||
assert.equal(r2.checks.length, 2);
|
||||
assert.ok(r2.checks.every(c => c.id.startsWith('anthropic.')));
|
||||
|
||||
// Filter by exact id
|
||||
const r3 = await runOlpDoctor({
|
||||
injectChecks: [
|
||||
fakeCheck('anthropic.cli_available', 'provider', 'ok'),
|
||||
fakeCheck('anthropic.oauth_token_present', 'provider', 'ok'),
|
||||
],
|
||||
checkFilter: 'anthropic.cli_available',
|
||||
});
|
||||
assert.equal(r3.checks.length, 1);
|
||||
assert.equal(r3.checks[0].id, 'anthropic.cli_available');
|
||||
});
|
||||
|
||||
it('33i — built-in checks run against a temp OLP_HOME (no config → fresh_install)', async () => {
|
||||
// skipNetwork: true so server.* checks don't run against the localhost port.
|
||||
const tmpEmpty = mkdtempSync(pathJoin(tmpdir(), 'olp-doc-33i-'));
|
||||
try {
|
||||
const result = await runOlpDoctor({
|
||||
olpHome: tmpEmpty,
|
||||
skipNetwork: true,
|
||||
// Don't load real provider plugins for this run; pass an empty Map.
|
||||
providersOverride: new Map(),
|
||||
});
|
||||
assert.equal(result.schema_version, DOCTOR_SCHEMA_VERSION);
|
||||
// config.exists must FAIL (no ~/.olp/config.json in tmpEmpty)
|
||||
const configCheck = result.checks.find(c => c.id === 'config.exists');
|
||||
assert.ok(configCheck, 'config.exists check should run');
|
||||
assert.equal(configCheck.status, 'fail');
|
||||
// kind must be fresh_install per the precedence rule
|
||||
assert.equal(result.kind, 'fresh_install');
|
||||
// ai_executable must include the mkdir + printf seed bootstrap
|
||||
assert.ok(result.next_action.ai_executable.some(c => c.startsWith('mkdir -p')));
|
||||
} finally {
|
||||
rmSync(tmpEmpty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('33j — anthropic plugin doctorChecks() returns the documented probe set', () => {
|
||||
// Plugin contract: ADR 0002 Amendment 7. anthropic.mjs ships
|
||||
// anthropic.cli_available + anthropic.oauth_token_present.
|
||||
const checks = anthropic.doctorChecks();
|
||||
assert.ok(Array.isArray(checks));
|
||||
const ids = checks.map(c => c.id).sort();
|
||||
assert.deepEqual(ids, ['anthropic.cli_available', 'anthropic.oauth_token_present']);
|
||||
assert.ok(checks.every(c => c.category === 'provider'));
|
||||
assert.ok(checks.every(c => typeof c.run === 'function'));
|
||||
});
|
||||
|
||||
it('33k — resolveProxyUrl: explicit > env OLP_PROXY_URL > OLP_PORT > default', () => {
|
||||
const SAVED_URL = process.env.OLP_PROXY_URL;
|
||||
const SAVED_PORT = process.env.OLP_PORT;
|
||||
try {
|
||||
delete process.env.OLP_PROXY_URL;
|
||||
delete process.env.OLP_PORT;
|
||||
// 1. default = http://127.0.0.1:4567
|
||||
assert.equal(resolveOlpProxyUrl(), 'http://127.0.0.1:4567');
|
||||
// 2. OLP_PORT env
|
||||
process.env.OLP_PORT = '9999';
|
||||
assert.equal(resolveOlpProxyUrl(), 'http://127.0.0.1:9999');
|
||||
// 3. OLP_PROXY_URL wins over OLP_PORT
|
||||
process.env.OLP_PROXY_URL = 'https://example.com:8443';
|
||||
assert.equal(resolveOlpProxyUrl(), 'https://example.com:8443');
|
||||
// 4. Explicit opts wins over everything
|
||||
assert.equal(resolveOlpProxyUrl({ proxyUrl: 'http://override:1234' }), 'http://override:1234');
|
||||
} finally {
|
||||
if (SAVED_URL === undefined) delete process.env.OLP_PROXY_URL;
|
||||
else process.env.OLP_PROXY_URL = SAVED_URL;
|
||||
if (SAVED_PORT === undefined) delete process.env.OLP_PORT;
|
||||
else process.env.OLP_PORT = SAVED_PORT;
|
||||
}
|
||||
});
|
||||
|
||||
it('33l — deriveKind / deriveNextAction pure-function shape', () => {
|
||||
// deriveKind unit
|
||||
assert.equal(deriveOlpKind([{ status: 'ok', category: 'server', id: 'a' }]), 'noop');
|
||||
assert.equal(deriveOlpKind([
|
||||
{ status: 'fail', category: 'server', id: 's.r' },
|
||||
{ status: 'fail', category: 'auth', id: 'a.o' },
|
||||
]), 'fix_server'); // server beats auth
|
||||
|
||||
// deriveNextAction aggregates evidence
|
||||
const na = deriveOlpNextAction([
|
||||
{ status: 'fail', id: 'x', category: 'provider', evidence: { fix_commands: ['cmd1'], human_steps: ['step1'] } },
|
||||
{ status: 'fail', id: 'y', category: 'provider', evidence: { fix_commands: ['cmd2'] } },
|
||||
{ status: 'ok', id: 'z', category: 'provider' },
|
||||
], 'fix_provider');
|
||||
assert.deepEqual(na.ai_executable, ['cmd1', 'cmd2']);
|
||||
assert.deepEqual(na.human_required, ['step1']);
|
||||
assert.equal(na.verify, 'olp doctor');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user