Repository navigation
Expand file tree
/
Copy pathtest.mjs
More file actions
344 lines (313 loc) · 21.8 KB
/
Copy pathtest.mjs
File metadata and controls
344 lines (313 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
// Self-contained end-to-end test. Builds a synthetic ~/.omp/agent fixture in a temp dir,
// spawns the server against it on an ephemeral port, asserts, then tears everything down.
// It never reads or writes your real omp data.
//
// node test.mjs
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { DatabaseSync } from "node:sqlite";
const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), "server.mjs");
const ACCOUNT_ID = "11111111-2222-3333-4444-555555555555";
const SID = "abc123";
let fails = 0;
const ok = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; };
const nowSec = () => Math.floor(Date.now() / 1000);
// ---- fixture --------------------------------------------------------------
function makeAgentDb(file, { withAccounts }) {
const db = new DatabaseSync(file);
db.exec(`
CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL,
credential_type TEXT NOT NULL, data TEXT NOT NULL, disabled_cause TEXT DEFAULT NULL,
identity_key TEXT DEFAULT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE model_usage (model_key TEXT PRIMARY KEY, last_used_at INTEGER NOT NULL);
CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE auth_schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL);
`);
db.prepare("INSERT INTO schema_version (version) VALUES (7)").run();
db.prepare("INSERT INTO auth_schema_version (id, version) VALUES (1, 3)").run();
db.prepare("INSERT INTO settings (key, value, updated_at) VALUES (?,?,?)").run("theme", "dark", nowSec());
db.prepare("INSERT INTO model_usage (model_key, last_used_at) VALUES (?,?)").run("anthropic/claude-sonnet-4", nowSec() - 600);
if (withAccounts) {
const t = nowSec();
const ins = db.prepare(`INSERT INTO auth_credentials
(provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?,?,?,?,?,?,?)`);
ins.run("anthropic", "oauth", JSON.stringify({
access: "sk-ant-oat-PRIMARYaccesstoken000", refresh: "sk-ant-ort-PRIMARYrefresh000",
expires: Date.now() + 3600_000, email: "primary@example.com", accountId: ACCOUNT_ID,
}), null, "anthropic:primary", t, t);
ins.run("anthropic", "oauth", JSON.stringify({
access: "sk-ant-oat-SECONDaccesstoken000", refresh: "sk-ant-ort-SECONDrefresh000",
expires: Date.now() + 7200_000, email: "second@example.com", accountId: "66666666-7777-8888-9999-000000000000",
}), null, "anthropic:second", t, t);
ins.run("openai", "api_key", JSON.stringify({ access: "sk-openaikey0000000000", email: "dev@example.com" }), null, "openai:dev", t, t);
const report = {
value: {
provider: "anthropic", fetchedAt: Date.now() - 60_000,
limits: [
{ id: "5h", label: "5-hour", status: "ok",
window: { id: "five_hour", label: "5h", durationMs: 18000000, resetsAt: Date.now() + 5400_000 },
amount: { used: 22, limit: 100, remaining: 78, remainingFraction: 0.78, usedFraction: 0.22, unit: "percent" } },
{ id: "7d", label: "weekly", status: "ok",
window: { id: "seven_day", label: "7d", durationMs: 604800000, resetsAt: Date.now() + 300000_000 },
amount: { used: 40, limit: 100, remaining: 60, remainingFraction: 0.6, usedFraction: 0.4, unit: "percent" } },
],
},
};
const c = db.prepare("INSERT INTO cache (key, value, expires_at) VALUES (?,?,?)");
c.run(`usage_cache:report:account:${ACCOUNT_ID}`, JSON.stringify(report), nowSec() + 900);
c.run("models:anthropic:etag", JSON.stringify({ value: "W/\"deadbeef\"" }), nowSec() + 86400);
}
db.close();
}
function makeHistoryDb(file) {
const db = new DatabaseSync(file);
db.exec(`CREATE TABLE history (id INTEGER PRIMARY KEY AUTOINCREMENT, prompt TEXT NOT NULL,
created_at INTEGER NOT NULL, cwd TEXT, session_id TEXT);`);
const ins = db.prepare("INSERT INTO history (prompt, created_at, cwd, session_id) VALUES (?,?,?,?)");
for (let i = 0; i < 6; i++) ins.run(`prompt number ${i}`, nowSec() - i * 3600, "/tmp/demo", SID);
db.close();
}
function makeModelsDb(file) {
const db = new DatabaseSync(file);
db.exec(`CREATE TABLE model_cache (provider_id TEXT PRIMARY KEY, version INTEGER NOT NULL,
updated_at INTEGER NOT NULL, authoritative INTEGER NOT NULL DEFAULT 0, models TEXT NOT NULL);`);
const ins = db.prepare("INSERT INTO model_cache (provider_id, version, updated_at, authoritative, models) VALUES (?,?,?,?,?)");
// updated_at is stored in MILLISECONDS by omp; the server divides by 1000.
ins.run("anthropic", 2, Date.now(), 1, JSON.stringify([
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", api: "messages", reasoning: true, contextWindow: 200000, maxTokens: 64000 },
{ id: "claude-haiku-4", name: "Claude Haiku 4", api: "messages", reasoning: false, contextWindow: 200000, maxTokens: 32000 },
]));
ins.run("openai", 1, Date.now(), 0, JSON.stringify([
{ id: "gpt-5", name: "GPT-5", api: "responses", reasoning: true, contextWindow: 400000, maxTokens: 128000 },
]));
db.close();
}
function msg(role, extra, tsMs) {
return JSON.stringify({ type: "message", timestamp: new Date(tsMs).toISOString(), message: { role, ...extra } });
}
const assistant = (text, model, cost, tokens, tsMs) => msg("assistant", {
model, content: [{ type: "text", text }],
usage: { totalTokens: tokens, cost: { total: cost, input: cost * 0.3, output: cost * 0.5, cacheRead: cost * 0.1, cacheWrite: cost * 0.1 } },
}, tsMs);
const user = (text, tsMs, attribution) => msg("user", { content: [{ type: "text", text }], ...(attribution ? { attribution } : {}) }, tsMs);
function makeSessions(dir) {
const t0 = Date.now() - 7200_000;
const sess = path.join(dir, "demo-project");
fs.mkdirSync(path.join(sess, `main_${SID}`), { recursive: true });
fs.writeFileSync(path.join(sess, `main_${SID}.jsonl`), [
user("fix the flaky test in the parser", t0),
assistant("Looking at the parser now.", "anthropic/claude-sonnet-4", 0.12, 4200, t0 + 5000),
JSON.stringify({ type: "message", timestamp: new Date(t0 + 6000).toISOString(), message: { role: "toolResult", toolName: "read", isError: false, content: [{ type: "text", text: "file contents here" }] } }),
user("now add a regression test", t0 + 60000),
assistant("Added the test.", "anthropic/claude-haiku-4", 0.03, 1100, t0 + 65000),
].join("\n") + "\n");
// subagent transcript (3 path segments -> attributed to the parent session)
fs.writeFileSync(path.join(sess, `main_${SID}`, "scout.jsonl"), [
user("map the parser module", t0 + 10000, "subagent"),
assistant("Parser lives in src/parse.js.", "anthropic/claude-haiku-4", 0.01, 800, t0 + 12000),
].join("\n") + "\n");
}
function buildFixture({ withAccounts = true, withSidecars = true, withSessions = true } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-lb-test-"));
const data = path.join(dir, "agent");
fs.mkdirSync(data, { recursive: true });
makeAgentDb(path.join(data, "agent.db"), { withAccounts });
if (withSidecars) {
makeHistoryDb(path.join(data, "history.db"));
makeModelsDb(path.join(data, "models.db"));
fs.writeFileSync(path.join(data, "config.yml"), "modelRoles:\n default: anthropic/claude-sonnet-4\n slow: anthropic/claude-opus-4\n plan: anthropic/claude-sonnet-4\nui:\n theme: dark\n");
fs.writeFileSync(path.join(data, "models.yml"), "providers:\n anthropic: {}\n");
}
if (withSessions) makeSessions(path.join(data, "sessions"));
// Stub `omp` binary for /api/chat: validates plumbing, proves OMP_LB_URL reaches the live
// server, and emits a canned --mode json event stream. POSIX shebang; chat stream test is
// skipped on win32 (validation tests still run everywhere).
const ompBin = path.join(dir, "omp-stub.mjs");
fs.writeFileSync(ompBin, `#!/usr/bin/env node
const out = (o) => process.stdout.write(JSON.stringify(o) + "\\n");
const a = process.argv.slice(2);
const val = (f) => a[a.indexOf(f) + 1];
const model = val("--model"), ext = val("-e") || "", sys = val("--system-prompt") || "";
const r = await fetch(process.env.OMP_LB_URL + "/api/overview").then((x) => x.json()).catch(() => null);
out({ type: "message_start", message: { role: "assistant" } });
out({ type: "message_update", assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "checking " } });
out({ type: "tool_execution_start", toolCallId: "t1", toolName: "lb", args: { path: "overview" }, intent: "fetch overview" });
out({ type: "tool_execution_end", toolCallId: "t1", toolName: "lb", result: {}, isError: false });
const reply = ["STUB", "model=" + model, "ext=" + (ext.endsWith("chat-ext.mjs") ? "ok" : "missing"),
"lock=" + (sys.includes("one tool: lb") ? "ok" : "missing"), "api=" + (r && r.accounts ? r.accounts.total : "fail")].join(" ");
out({ type: "message_update", assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: reply } });
out({ type: "message_end", message: { role: "assistant", model, usage: { totalTokens: 42, cost: { total: 0.001 } }, content: [] } });
out({ type: "agent_end" });
`, { mode: 0o755 });
return { root: dir, data, agentDb: path.join(data, "agent.db"), backupDir: path.join(dir, "backups"), ompBin };
}
// ---- server harness -------------------------------------------------------
function startServer(fx, extraArgs = []) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [SERVER, "--port", "0", ...extraArgs], {
env: { ...process.env, OMP_DB: fx.agentDb, OMP_LB_BACKUP_DIR: fx.backupDir, OMP_LB_PORT: "", OMP_LB_OMP_BIN: fx.ompBin || "" },
stdio: ["ignore", "pipe", "pipe"],
});
let out = "", err = "";
const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`server did not start in 15s\n${out}\n${err}`)); }, 15000);
child.stdout.on("data", (d) => {
out += d;
const m = /http:\/\/127\.0\.0\.1:(\d+)/.exec(out);
if (m) { clearTimeout(timer); resolve({ child, base: m[0], port: +m[1], stdout: () => out, stderr: () => err }); }
});
child.stderr.on("data", (d) => (err += d));
child.on("exit", (code) => { clearTimeout(timer); reject(new Error(`server exited early (code ${code})\n${out}\n${err}`)); });
});
}
const stopServer = (s) => new Promise((r) => { s.child.removeAllListeners("exit"); s.child.on("exit", r); s.child.kill("SIGTERM"); setTimeout(() => { s.child.kill("SIGKILL"); r(); }, 3000).unref?.(); });
// ---- scenario 1: populated fixture ----------------------------------------
console.log("\n— populated fixture —");
let fx = buildFixture();
let srv = await startServer(fx);
let get = async (p) => (await fetch(srv.base + p)).json();
let post = async (p, b) => (await fetch(srv.base + p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) })).json();
try {
const ov = await get("/api/overview");
ok(ov.accounts.total === 3, `overview: 3 accounts, ${ov.accounts.active} active`);
ok(ov.has.history && ov.has.models, `overview: history=${ov.has.history} models=${ov.has.models}`);
ok(ov.sessions.total === 1 && ov.sessions.prompts === 2, `overview: ${ov.sessions.total} session / ${ov.sessions.prompts} prompts`);
ok(ov.roles.default === "anthropic/claude-sonnet-4", `overview: default role = ${ov.roles.default}`);
ok(ov.backupDir === fx.backupDir, `overview: backupDir honours OMP_LB_BACKUP_DIR`);
const acc = await get("/api/accounts");
const anth = acc.accounts.filter((a) => a.provider === "anthropic");
ok(anth.length === 2, `accounts: ${anth.length} anthropic`);
const primary = anth.find((a) => a.accountId === ACCOUNT_ID);
ok(primary.quota.length === 2, `accounts: primary has ${primary.quota.length} quota windows`);
ok(primary.quota[0].windowId === "five_hour", `accounts: quota sorted shortest window first`);
ok(primary.accessMasked === "sk-ant…n000" && !("access" in primary), `accounts: token masked, raw token withheld from list`);
ok(primary.expired === false, `accounts: unexpired token reported as live`);
const ses = await get("/api/sessions?limit=5");
ok(ses.sessions.length === 1 && ses.total === 1, `sessions: ${ses.sessions.length} listed / ${ses.total} total`);
const s0 = ses.sessions[0];
ok(s0.id === SID && s0.slug === "demo-project", `sessions: id/slug derived from path (${s0.id}, ${s0.slug})`);
ok(s0.counts.prompts === 2 && s0.counts.assistant === 2 && s0.counts.tools === 1, `sessions: counts p=${s0.counts.prompts} a=${s0.counts.assistant} t=${s0.counts.tools}`);
ok(s0.subagents.length === 1 && Math.abs(s0.subagentCost - 0.01) < 1e-9, `sessions: subagent rolled into parent ($${s0.subagentCost})`);
ok(Math.abs(s0.cost - 0.16) < 1e-9, `sessions: cost includes subagent ($${s0.cost.toFixed(3)})`);
const det = await get("/api/sessions/" + encodeURIComponent(s0.id));
ok(det.summary && det.messages.length === 5, `session detail: ${det.messages.length} messages (${det.totalRecords} records)`);
ok(det.messages.some((m) => m.kind === "tool" && m.toolName === "read"), `session detail: tool result rendered`);
ok((await get("/api/sessions/nope")).error === "session not found", `session detail: unknown id -> 404 body`);
const srch = await get("/api/search?q=" + encodeURIComponent("regression"));
ok(srch.results.length === 1 && srch.results[0].sessionId === SID, `search: ${srch.results.length} result linked to session`);
ok((await get("/api/search?q=")).results.length === 0, `search: empty query returns nothing`);
const cst = await get("/api/cost");
ok(Math.abs(cst.total - 0.16) < 1e-9 && cst.byModel.length === 2, `cost: $${cst.total.toFixed(2)} across ${cst.byModel.length} models`);
const mdl = await get("/api/models");
ok(mdl.providers.length === 2 && mdl.providers[0].models.length === 2, `models: ${mdl.providers.length} providers`);
ok(mdl.providers.every((p) => p.updatedAt == null || p.updatedAt < 2e10), `models: updatedAt normalized to seconds`);
const usg = await get("/api/usage");
ok(usg.accounts.length === 3 && usg.activity.length > 0, `usage: ${usg.accounts.length} accounts, ${usg.activity.length} activity days`);
const cache = await get("/api/cache");
ok(cache.entries.length === 2 && cache.entries.some((e) => e.kind === "usage"), `cache: ${cache.entries.length} entries, usage kind tagged`);
const cfg = await get("/api/config");
ok(cfg.configYml.includes("modelRoles") && cfg.paths.agentDb === fx.agentDb, `config: yml + paths point at the fixture`);
// ---- chat (SSE endpoint + locked tool extension) ----
const chatStream = async (b) => {
const res = await fetch(srv.base + "/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
if ((res.headers.get("content-type") || "").includes("json")) return { status: res.status, json: await res.json(), events: [] };
const text = await res.text();
return { status: res.status, events: [...text.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1])) };
};
let cv = await chatStream({ messages: [{ role: "user", content: "hi" }] });
ok(cv.status === 400 && cv.json.error === "model required", `chat: missing model -> 400`);
cv = await chatStream({ model: "anthropic/claude-sonnet-4", messages: [{ role: "assistant", content: "hi" }] });
ok(cv.status === 400 && /user message/.test(cv.json.error), `chat: no trailing user message -> 400`);
if (process.platform !== "win32") {
const cr = await chatStream({ model: "anthropic/claude-sonnet-4", messages: [{ role: "user", content: "quota?" }] });
const done = cr.events.find((e) => e.t === "done");
const tool = cr.events.find((e) => e.t === "tool");
ok(cr.status === 200 && cr.events.filter((e) => e.t === "delta").length >= 2 && tool?.path === "overview", `chat: SSE stream carries deltas + tool lifecycle`);
ok(!!done && done.reply.includes("model=anthropic/claude-sonnet-4") && done.reply.includes("ext=ok") && done.reply.includes("lock=ok"), `chat: omp invoked with model + chat-ext + locked system prompt`);
ok(!!done && done.reply.includes("api=3"), `chat: OMP_LB_URL reaches the live server from the spawned process`);
ok(!!done && done.usage?.tokens === 42 && Math.abs(done.usage.costUsd - 0.001) < 1e-9, `chat: usage passthrough`);
}
// chat-ext contract: read-only whitelist against the live server
process.env.OMP_LB_URL = srv.base;
const defs = [];
const fakePi = { zod: { object: (o) => o, string: () => ({ describe: () => ({}) }) }, setLabel: () => {}, registerTool: (d) => defs.push(d), on: () => {} };
(await import("./chat-ext.mjs")).default(fakePi);
const lb = defs.find((d) => d.name === "lb");
ok(!!lb && lb.loadMode === "essential", `chat-ext: registers essential lb tool`);
ok((await lb.execute("t", { path: "overview" })).content[0].text.includes('"accounts"'), `chat-ext: lb fetches live overview JSON`);
ok((await lb.execute("t", { path: "search?q=regression" })).content[0].text.includes(SID), `chat-ext: query paths pass through`);
const rejects = async (p) => { try { await lb.execute("t", { path: p }); return false; } catch { return true; } };
ok(await rejects("account/reveal"), `chat-ext: non-whitelisted path rejected`);
ok(await rejects("../server.mjs"), `chat-ext: traversal rejected`);
ok(await rejects("sessions/../../etc"), `chat-ext: crafted session id rejected`);
// ---- mutations ----
const target = primary;
let r = await post("/api/account/disable", { id: target.id });
ok(r.accounts.find((a) => a.id === target.id).disabled === true, `disable #${target.id}`);
r = await post("/api/account/enable", { id: target.id });
ok(r.accounts.find((a) => a.id === target.id).disabled === false, `enable #${target.id}`);
r = await post("/api/account/keep-only", { id: target.id, provider: "anthropic" });
ok(r.accounts.filter((a) => a.provider === "anthropic" && a.id !== target.id).every((a) => a.disabled), `keep-only disables other anthropic`);
ok(r.accounts.find((a) => a.provider === "openai").disabled === false, `keep-only leaves other providers alone`);
for (const a of r.accounts.filter((a) => a.id !== target.id && a.disabled)) await post("/api/account/enable", { id: a.id });
const rev = await post("/api/account/reveal", { id: target.id });
ok(rev.access === "sk-ant-oat-PRIMARYaccesstoken000", `reveal returns the real token`);
const cd = await post("/api/cache/delete", { key: cache.entries[0].key });
ok(cd.entries.length === 1, `cache delete one (2 -> ${cd.entries.length})`);
const cc = await post("/api/cache/clear", { scope: "all" });
ok(cc.entries.length === 0, `cache clear all`);
r = await post("/api/account/delete", { id: target.id });
ok(r.accounts.length === 2, `delete removes one (3 -> ${r.accounts.length})`);
const bk = await post("/api/backup", {});
ok(bk.ok && bk.path.startsWith(fx.backupDir), `backup written under OMP_LB_BACKUP_DIR`);
ok(fs.existsSync(bk.path) && fs.statSync(bk.path).size > 0, `backup file exists on disk`);
ok(bk.backups.length > 1, `backup list grows (${bk.backups.length} snapshots, incl. per-write ones)`);
const restored = new DatabaseSync(bk.path, { readOnly: true });
ok(restored.prepare("SELECT COUNT(*) n FROM auth_credentials").get().n === 2, `backup is a readable snapshot of current state`);
restored.close();
// static assets + traversal
const idx = await fetch(srv.base + "/");
ok(idx.status === 200 && (await idx.text()).includes("<"), `static: / serves index.html`);
ok((await fetch(srv.base + "/../server.mjs")).status === 404, `static: path traversal blocked`);
} finally {
await stopServer(srv);
}
// ---- scenario 2: brand-new install (empty db, no sidecars, no sessions) ----
console.log("\n— fresh install (empty db, no history/models/sessions) —");
const fx2 = buildFixture({ withAccounts: false, withSidecars: false, withSessions: false });
const srv2 = await startServer(fx2);
get = async (p) => (await fetch(srv2.base + p)).json();
try {
const ov = await get("/api/overview");
ok(ov.accounts.total === 0 && ov.sessions.total === 0, `empty: overview renders with zero data`);
ok(ov.has.history === false && ov.has.models === false, `empty: missing sidecar dbs reported, not fatal`);
const forced = await get("/api/overview?refresh=1");
ok(forced.accounts.total === 0 && !forced.error, `empty: overview?refresh=1 still 200`);
for (const p of ["/api/accounts", "/api/sessions", "/api/search?q=x", "/api/models", "/api/usage", "/api/cost", "/api/cache", "/api/config", "/api/backups"]) {
const res = await fetch(srv2.base + p);
const body = await res.json();
ok(res.status === 200 && !body.error, `empty: ${p} -> 200`);
}
} finally {
await stopServer(srv2);
}
// ---- scenario 3: startup guards -------------------------------------------
console.log("\n— startup guards —");
const failToStart = (args, env) => new Promise((r) => {
const c = spawn(process.execPath, [SERVER, ...args], { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
let err = "";
c.stderr.on("data", (d) => (err += d));
c.on("exit", (code) => r({ code, err }));
});
let g = await failToStart(["--port", "0"], { OMP_DB: path.join(fx2.root, "nope", "agent.db"), OMP_LB_BACKUP_DIR: fx2.backupDir });
ok(g.code === 1 && g.err.includes("agent.db not found"), `guard: missing agent.db exits 1 with guidance`);
g = await failToStart(["--port", "abc"], { OMP_DB: fx2.agentDb, OMP_LB_BACKUP_DIR: fx2.backupDir });
ok(g.code === 1 && g.err.includes("invalid port"), `guard: non-numeric --port exits 1`);
// ---- teardown -------------------------------------------------------------
for (const d of [fx.root, fx2.root]) fs.rmSync(d, { recursive: true, force: true });
console.log(fails ? `\n${fails} FAILED` : "\nALL PASS");
process.exit(fails ? 1 : 0);