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
This commit is contained in:
Lyubomir Bozhinov authored and GitHub committed 2026-08-23 16:24:20 -04:00
1 parent c352f34843
commit 41f0c14039
4 files changed
+163 -8

No files matched your search

+7 -2
View File
@@ -7,7 +7,9 @@
* 2. Dispatch a t3mp3st hunter against the target's URL.
* - --hunter=live : direct LLM (OpenRouter / Anthropic / OpenAI auto-detect)
* - --hunter=t3mp3st: drive t3mp3st's /api/general/auto (full platform)
* - --hunter=stub : synthesized transcript (no LLM, for plumbing tests)
* - --hunter=stub : synthesized transcript (no LLM, for plumbing tests).
* NB: only the HUNTER is offline — the OBSIDIVM service
* is still required for the spec (step 1) and scoring (3).
* 3. Submit the agent transcript to OBSIDIVM's /api/score/text.
* 4. Persist the run via OBSIDIVM's /api/runs ledger + per-target sessions.
* 5. Print a per-target table + suite aggregate (weighted % + grade).
@@ -105,9 +107,12 @@ function help() {
Options:
--target <id> Target id (repeatable). Default: all 14 OBSIDIVM targets.
--hunter <stub|live|t3mp3st>
stub: synthesized transcript (no LLM)
stub: synthesized transcript (no LLM; the OBSIDIVM
service is still required for spec + scoring)
live: direct LLM call
t3mp3st: drive t3mp3st's /api/general/auto
All modes need the OBSIDIVM service (see --obsidivm / docs/OBSIDIVM.md); live
and t3mp3st additionally need an LLM key.
--obsidivm <url> OBSIDIVM base URL (default http://127.0.0.1:4200)
--t3mp3st <url> t3mp3st base URL (default http://127.0.0.1:3333)
--model <name> LLM model id (default claude-opus-4-7)
+33
View File
@@ -0,0 +1,33 @@
// Types for the OBSIDIVM bridge client (consumed by the vitest test).
// Runnable logic lives in the sibling .mjs (scripts run under bare `node`, no build step).
/** A network-level failure (connection refused / DNS / timeout) carries this flag,
* distinguishing an unreachable service from an HTTP-status error (which sets `status`). */
export interface ObsidivmError extends Error {
unreachable?: boolean;
status?: number;
body?: unknown;
}
export interface ObsidivmClient {
readonly baseUrl: string;
health(): Promise<unknown>;
getSpec(): Promise<{ version?: string; targets?: Array<{ id: string; name?: string; port?: number }> } & Record<string, unknown>>;
status(): Promise<unknown>;
deploy(): Promise<unknown>;
destroy(): Promise<unknown>;
startTarget(id: string): Promise<unknown>;
stopTarget(id: string): Promise<unknown>;
scoreText(targetId: string, text: string): Promise<Record<string, unknown>>;
createRun(payload?: Record<string, unknown>): Promise<Record<string, unknown>>;
attachSession(runId: string, payload?: Record<string, unknown>): Promise<unknown>;
proposeGoalposts(payload?: Record<string, unknown>): Promise<unknown>;
evolveStart(payload?: Record<string, unknown>): Promise<unknown>;
evolveStop(): Promise<unknown>;
cloudgoatInstall(): Promise<unknown>;
cloudgoatCreate(scenario: string): Promise<unknown>;
cloudgoatDestroy(scenario: string): Promise<unknown>;
cloudgoatDestroyAll(): Promise<unknown>;
}
export function obsidivm(opts?: { baseUrl?: string; timeoutMs?: number }): ObsidivmClient;
+34 -6
View File
@@ -31,12 +31,40 @@ export function obsidivm({ baseUrl, timeoutMs } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), t);
try {
const res = await fetch(url, {
method,
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
signal: ctrl.signal,
});
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 }; }