Files
t3mp3st/scripts/obsidivm-bridge.mjs
T
Lyubomir Bozhinov 41f0c14039 fix(obsidivm): actionable error when the range service is unreachable (#156) (#158)
* test(obsidivm): RED — bridge should give an actionable error when the service is down

The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not
running the bridge throws a bare 'fetch failed' (surfaced as 'FATAL: fetch
failed'), naming neither the service, the URL, nor how to start it. Pins that a
connection failure becomes an actionable, self-service error.

Fails on main: the message is 'fetch failed'.

Refs #156

* fix(obsidivm): actionable error when the range service is unreachable

The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not
running the bridge's shared call() let the raw 'fetch failed' propagate, which
surfaced as an opaque 'FATAL: fetch failed' (#156) naming neither the service,
the URL, nor how to start it — even in --hunter stub, whose grading step still
needs it. Wrap the fetch in call() (the single choke point for every OBSIDIVM
request) so a connection failure becomes: 'OBSIDIVM service unreachable at <url>
... Start it per docs/OBSIDIVM.md (python3 range.py, listens on :4200), or set
OBSIDIVM_URL', flagged err.unreachable to distinguish it from an HTTP-status
error; a timeout is reported distinctly. Clarify in the bench --help/usage that
every hunter mode (stub included) needs the OBSIDIVM service, live/t3mp3st also
a key. Adds scripts/obsidivm-bridge.d.mts so the new test types cleanly.

Closes #156

* fix(obsidivm): distinguish invalid-URL from unreachable; test timeout path

Adversarial review of the diff found two gaps:
- A malformed OBSIDIVM_URL (ERR_INVALID_URL) was folded into the 'service
  unreachable — is it running?' path, misdirecting the user to start a service
  when the real fix is the URL. Now branched to a distinct 'Invalid OBSIDIVM
  base URL … set OBSIDIVM_URL' error, no err.unreachable flag.
- The timeout (AbortError) branch had no coverage. Added a test using a
  never-responding TCP server + short timeoutMs asserting 'timed out after Nms'
  and NOT unreachable; and a test for the invalid-URL branch.

Also completed the .d.mts stub with the cloudgoat* methods it was missing.

Refs #156
2026-08-23 16:24:20 -04:00

208 lines
9.0 KiB
JavaScript

/**
* T3MP3ST ↔ OBSIDIVM bridge.
*
* Thin HTTP client over OBSIDIVM's range API (default http://127.0.0.1:4200).
* Importable as a module from other scripts:
*
* import { obsidivm } from './obsidivm-bridge.mjs';
* const o = obsidivm();
* const spec = await o.getSpec();
* const verdict = await o.scoreText('dvwa', agentOutput);
* const run = await o.createRun({...});
* await o.attachSession(run.run_id, {...});
*
* Also runnable as a CLI for quick smoke checks:
*
* node scripts/obsidivm-bridge.mjs health
* node scripts/obsidivm-bridge.mjs spec
* node scripts/obsidivm-bridge.mjs status
* node scripts/obsidivm-bridge.mjs score <target_id> <text-file>
*/
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
export function obsidivm({ baseUrl, timeoutMs } = {}) {
const base = (baseUrl || process.env.OBSIDIVM_URL || 'http://127.0.0.1:4200').replace(/\/$/, '');
const t = timeoutMs || 30000;
async function call(method, path, body) {
const url = base + path;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), t);
try {
let res;
try {
res = await fetch(url, {
method,
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
signal: ctrl.signal,
});
} catch (e) {
// The fetch itself failed — distinct from an HTTP-status error below. A
// bare `fetch failed` here is the #156 dead-end; make it self-service.
// Three sub-cases, kept distinct so the message points at the real fix:
// AbortError → the request timed out
// ERR_INVALID_URL → OBSIDIVM_URL is malformed (a config error, NOT a
// down service — don't send the user to start one)
// otherwise → nothing is listening on the base URL
if (e?.name === 'AbortError') {
throw new Error(`OBSIDIVM ${method} ${path} timed out after ${t}ms (${base}).`);
}
if (e?.cause?.code === 'ERR_INVALID_URL') {
throw new Error(
`Invalid OBSIDIVM base URL ${JSON.stringify(base)} — set OBSIDIVM_URL (or --obsidivm) ` +
`to a full URL like http://127.0.0.1:4200.`,
);
}
const err = new Error(
`OBSIDIVM service unreachable at ${base} — ${method} ${path} failed ` +
`(${e?.cause?.code || e?.message || 'network error'}). Is it running? ` +
`Start it per docs/OBSIDIVM.md (python3 range.py, listens on :4200), or set OBSIDIVM_URL.`,
);
err.unreachable = true;
err.cause = e;
throw err;
}
const text = await res.text();
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; }
if (!res.ok) {
const err = new Error(`OBSIDIVM ${method} ${path} → ${res.status}: ${(parsed.error || text).slice(0, 200)}`);
err.status = res.status;
err.body = parsed;
throw err;
}
return parsed;
} finally {
clearTimeout(timer);
}
}
return {
baseUrl: base,
// --- discovery --------------------------------------------------------
async health() { return call('GET', '/api/health'); },
async getSpec() { return call('GET', '/api/spec'); },
async status() { return call('GET', '/api/status'); },
// --- target lifecycle -------------------------------------------------
async deploy() { return call('POST', '/api/deploy'); },
async destroy() { return call('POST', '/api/destroy'); },
async startTarget(id) { return call('POST', `/api/start/${encodeURIComponent(id)}`); },
async stopTarget(id) { return call('POST', `/api/stop/${encodeURIComponent(id)}`); },
// --- evidence scoring -------------------------------------------------
/**
* Submit an agent's free-form output text for evidence-aware scoring
* against a target's canonical expected findings.
*
* @param {string} targetId - one of /api/spec[].id (e.g. 'dvwa')
* @param {string} text - the agent's transcript / report
* @returns {Promise<{
* target_id: string, found: number, total: number,
* weighted_percent: number, grade: string,
* results: Array<{id, title, severity, detected, evidence}>
* }>}
*/
async scoreText(targetId, text) {
return call('POST', '/api/score/text', { target_id: targetId, text });
},
// --- run ledger -------------------------------------------------------
async createRun(payload = {}) { return call('POST', '/api/runs', payload); },
async attachSession(runId, payload = {}) {
return call('POST', `/api/runs/${encodeURIComponent(runId)}/sessions`, payload);
},
// --- evolution + goalposts -------------------------------------------
async proposeGoalposts(payload = {}) { return call('POST', '/api/goalposts/propose', payload); },
async evolveStart(payload = {}) { return call('POST', '/api/evolve/start', payload); },
async evolveStop() { return call('POST', '/api/evolve/stop', {}); },
// --- cloudgoat (optional, mostly skip) -------------------------------
async cloudgoatInstall() { return call('POST', '/api/cloudgoat/install'); },
async cloudgoatCreate(scenario) { return call('POST', `/api/cloudgoat/create/${encodeURIComponent(scenario)}`); },
async cloudgoatDestroy(scenario) { return call('POST', `/api/cloudgoat/destroy/${encodeURIComponent(scenario)}`); },
async cloudgoatDestroyAll() { return call('POST', '/api/cloudgoat/destroy-all'); },
};
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function cli(argv) {
const [cmd, ...rest] = argv;
const o = obsidivm();
switch (cmd) {
case undefined:
case '-h':
case '--help':
console.log(`obsidivm-bridge CLI
health Ping the range
spec Fetch canonical /api/spec
status Target / Docker status
score <target_id> <textfile> POST <textfile> contents to /api/score/text
start <target_id> docker run a single target
stop <target_id> docker rm a single target
deploy deploy all (slow, downloads images)
destroy tear all down
evolve-start kick off evolution loop
evolve-stop stop evolution
Env:
OBSIDIVM_URL default http://127.0.0.1:4200
`);
return 0;
case 'health': console.log(JSON.stringify(await o.health(), null, 2)); return 0;
case 'spec': {
const s = await o.getSpec();
console.log(`version: ${s.version} expected: ${s.expected_count} targets: ${(s.targets || []).length}`);
console.log(`severity weights: ${JSON.stringify(s.severity_weights)}`);
console.log(`targets:`);
for (const t of s.targets || []) {
console.log(` ${t.id.padEnd(12)} :${t.port} expected=${(t.expected || t.expected_findings || []).length} (${t.name})`);
}
return 0;
}
case 'status': console.log(JSON.stringify(await o.status(), null, 2)); return 0;
case 'score': {
const [tid, file] = rest;
if (!tid || !file) { console.error('usage: score <target_id> <textfile>'); return 2; }
const text = fs.readFileSync(file, 'utf8');
const v = await o.scoreText(tid, text);
console.log(`${v.target_id} ${v.found}/${v.total} weighted=${v.weighted_percent}% grade=${v.grade}`);
const hits = (v.results || []).filter(r => r.detected);
for (const h of hits) {
const kw = h.evidence && h.evidence.keyword ? ` kw=${h.evidence.keyword}` : '';
console.log(` ✓ [${h.severity.toUpperCase().padEnd(8)}] ${h.title}${kw}`);
}
const miss = (v.results || []).filter(r => !r.detected);
for (const m of miss) {
console.log(` · [${m.severity.toUpperCase().padEnd(8)}] ${m.title}`);
}
return 0;
}
case 'start': console.log(JSON.stringify(await o.startTarget(rest[0]), null, 2)); return 0;
case 'stop': console.log(JSON.stringify(await o.stopTarget(rest[0]), null, 2)); return 0;
case 'deploy': console.log(JSON.stringify(await o.deploy(), null, 2)); return 0;
case 'destroy': console.log(JSON.stringify(await o.destroy(), null, 2)); return 0;
case 'evolve-start': console.log(JSON.stringify(await o.evolveStart(), null, 2)); return 0;
case 'evolve-stop': console.log(JSON.stringify(await o.evolveStop(), null, 2)); return 0;
default:
console.error(`unknown command: ${cmd}`);
return 2;
}
}
if (import.meta.url === `file://${process.argv[1]}` ||
process.argv[1] === fileURLToPath(import.meta.url)) {
cli(process.argv.slice(2))
.then(code => process.exit(code))
.catch(e => { console.error('FATAL:', e.message); process.exit(1); });
}