Files
stack/packages/harness/tests/runner.test.mjs
T
jason.woltjeandClaude Opus 5.5 badfa52b9d harness: a new PM run reads current launch state and open tasks before each turn, and old messages show their age, row 109 (#1603)
The broker gains a read-only state verb (launch state with the event that set it, open tasks capped). The runner fetches it before each turn and puts a "Current state" block at the top of the request; a failed read fails the turn. turnRequest() adds the message time. roles/pm.md gets one rule: the block is the truth for launch state and task status. Seat session, seat sandbox and cli launcher tests re-anchored to the new request shape.

Built by Researcher, packet 875c515e9. Filbert approved round 3, comment 28333, candidate manifest 0a62225e (11 paths). Sage gate on 9774d70db plus row109.patch: every package node suite and every scripts/test-*.sh green on the first run, skipped 0 in bus, harness and seat. Vikunja #259.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:12:54 -05:00

444 lines
22 KiB
JavaScript

// The runner against a real broker on a real socket, with a fake adapter.
// The broker runs in this process; the runner is a child, as the host
// starts it (minus the PID namespace, which packages/seat tests).
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { Store } from "../../bus/src/store.mjs";
import { Broker, BusError } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
import { EXIT, founderCheck, stateBlock, turnRequest } from "../src/runner.mjs";
import { scratch } from "./helpers.mjs";
const RUNNER = new URL("../src/runner.mjs", import.meta.url).pathname;
const FAKE = new URL("./fixtures/fake-adapter.mjs", import.meta.url).pathname;
const businesses = {
demo: {
id: "demo",
human: "jason",
arbiters: { technical: "pm", delivery: "pm" },
launch: { by: "pm", instances: ["coder"], max: { opus: 2, sonnet: 2 } },
roles: {
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
},
};
test("founderCheck: founder variables, then a needed service without a usable token", () => {
const policy = { needs: ["gitea"], credentials: [{ service: "gitea", state: "valid" }] };
assert.equal(founderCheck(policy, {}), null);
assert.equal(founderCheck({ needs: [] }, { PATH: "/bin" }), null);
assert.match(founderCheck(policy, { GITEA_TOKEN: "x", SSH_AUTH_SOCK: "/s" }), /GITEA_TOKEN, SSH_AUTH_SOCK/);
assert.match(founderCheck(policy, { MOSAIC_GITEA_CREDENTIAL_FILE: "" }), /MOSAIC_GITEA_CREDENTIAL_FILE/);
for (const state of ["expiring", "rotation-due"]) assert.equal(founderCheck({ needs: ["x"], credentials: [{ service: "x", state }] }, {}), null);
assert.match(founderCheck({ needs: ["vikunja"], credentials: [{ service: "vikunja", state: "expired" }] }, {}), /no usable role token for vikunja/);
assert.match(founderCheck({ needs: ["gitea", "vikunja"], credentials: [{ service: "gitea", state: "valid" }] }, {}), /vikunja/);
});
test("turnRequest names the sender, class, reply and decision", () => {
assert.equal(turnRequest({ id: "m1", from_role: "pm", class: "ASSIGNMENT", body: "do it" }), "Message m1 from pm, class ASSIGNMENT:\n\ndo it");
assert.equal(
turnRequest({ id: "m2", from_role: "cto", class: "RESULT", in_reply_to: "m1", decision: "d1", body: "b" }),
"Message m2 from cto, class RESULT, in reply to m1, citing decision d1:\n\nb",
);
// Row 90 (#1584): a human message's request id, which task_create cites; none for a role's.
assert.equal(
turnRequest({ id: "m3", from_role: "jason", class: "REQUEST", request: "e7", body: "b" }),
"Message m3 from jason, class REQUEST, request e7:\n\nb",
);
assert.equal(turnRequest({ id: "m4", from_role: "cto", class: "RESULT", request: null, body: "b" }), "Message m4 from cto, class RESULT:\n\nb");
});
// Row 109 (#1603): the header carries when the message was sent, not when it is read.
test("turnRequest shows the message's own sent time, a day old as well as new", () => {
const sent = "2026-10-12T09:30:00.000Z";
assert.equal(
turnRequest({ id: "m5", from_role: "jason", class: "REQUEST", at: sent, body: "resume" }),
`Message m5 from jason, sent ${sent}, class REQUEST:\n\nresume`,
);
assert.equal(turnRequest({ id: "m6", from_role: "cto", class: "RESULT", at: sent, in_reply_to: "m5", body: "b" }), `Message m6 from cto, sent ${sent}, class RESULT, in reply to m5:\n\nb`);
});
test("stateBlock names the launch state with its time and event, and the open tasks with the cap", () => {
const read = "2026-10-13T10:00:00.000Z";
const restored = stateBlock({ launch: { state: "allowed", at: "2026-10-12T20:00:00.000Z", event: "ev-9" }, tasks: { open: [{ task_ref: "vikunja:3/7", title: "Seven" }], total: 1, cap: 50 } }, read);
assert.equal(restored, "Current state, read at 2026-10-13T10:00:00.000Z:\nLaunches: allowed since 2026-10-12T20:00:00.000Z (event ev-9).\nOpen tasks: 1.\n- vikunja:3/7 Seven\n");
const capped = stateBlock({ launch: { state: "allowed", at: null, event: null }, tasks: { open: [{ task_ref: "vikunja:3/1", title: "One" }], total: 9, cap: 1 } }, read);
assert.match(capped, /Launches: allowed \(never switched\)\./);
assert.match(capped, /Open tasks: 9, the first 1 of cap 1\./);
});
async function setup(t, { session = {}, policy = {}, tools } = {}) {
const dir = scratch(t);
const store = new Store(dir);
const broker = new Broker({ store, businesses });
const brokerSocket = join(dir, "bus.sock");
const server = await serve({ broker, path: brokerSocket });
t.after(async () => {
await server.close().catch(() => {});
store.close();
});
const runDir = join(dir, "run");
const bundle = join(runDir, "bundle");
mkdirSync(bundle, { recursive: true, mode: 0o700 });
const adapter = join(dir, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`);
writeFileSync(join(bundle, "prompt.md"), "# test\n");
writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: join(dir, "ws"), tools: [], typed: ["send_message", "launch"], network: "none", needs: [], credentials: [], ...policy }));
writeFileSync(join(bundle, "tools.json"), JSON.stringify(tools ?? [
{ name: "send_message", verb: "message.send", action: "message.send" },
{ name: "launch", verb: "launch", action: "role.launch" },
]));
mkdirSync(join(dir, "ws"));
const launches = [];
const launchSocket = join(dir, "launch.sock");
const launchServer = createServer((s) => {
s.once("data", (b) => {
const r = JSON.parse(b);
launches.push(r);
s.end(JSON.stringify(r.instance === "coder" ? { ok: false, error: "capacity-full" } : { ok: true, result: { run: "new-run" } }) + "\n");
});
});
await new Promise((r) => launchServer.listen(launchSocket, r));
t.after(() => launchServer.close());
writeFileSync(join(runDir, "session.json"), JSON.stringify({
business: "demo",
instance: "coder",
run: "coder-run",
launchedBy: "pm",
harness: "pi",
provider: "fake",
model: "fake-model",
brokerSocket,
launchSocket,
workspace: join(dir, "ws"),
sessionDir: join(runDir, "session"),
adapter,
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
toolSocket: join(runDir, "tools.sock"),
turnMarker: join(runDir, "turn.done"),
pollInterval: 50,
turnTimeout: 20,
...session,
}));
const cap = broker.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi" });
const pm = broker.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" });
const call = (c, verb, args = {}) => broker.request(c, { verb, args });
call(pm, "role.claim");
const human = broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
return { dir, runDir, store, broker, server, cap, pm, human, call, launches, pids: join(dir, "pids") };
}
// A runner that hasn't exited after a minute is killed, so a test that
// expected an exit fails on the code instead of hanging.
function startRunner(runDir, cap, env = {}, input = cap === null ? "" : JSON.stringify({ cap }) + "\n") {
const child = spawn(process.execPath, [RUNNER, runDir], {
stdio: ["pipe", "ignore", "pipe"],
env: { PATH: process.env.PATH, ...env },
});
let stderr = "";
child.stderr.on("data", (b) => (stderr += b));
child.stdin.on("error", () => {});
child.stdin.end(input);
const clock = setTimeout(() => child.kill("SIGKILL"), 60_000);
const exited = once(child, "exit").then(([code, signal]) => {
clearTimeout(clock);
return { code, signal, stderr };
});
return { child, exited, stderr: () => stderr };
}
async function until(fn, ms = 15_000) {
const end = Date.now() + ms;
for (;;) {
const v = fn();
if (v) return v;
if (Date.now() > end) throw new Error("timed out waiting");
await new Promise((r) => setTimeout(r, 25));
}
}
const replies = (ctx) => () => {
const got = ctx.call(ctx.pm, "message.receive");
ctx.inbox.push(...got);
return ctx.inbox.length ? ctx.inbox : null;
};
test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hello", class: "REQUEST" });
const [reply] = await until(replies(ctx));
assert.equal(reply.class, "RESULT");
assert.equal(reply.from_role, "coder");
assert.equal(reply.from_run, "coder-run");
assert.equal(reply.in_reply_to, sent.id);
const sentAt = ctx.store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
assert.match(reply.body, new RegExp(`^echo: Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\nMessage ${sent.id} from pm, sent ${sentAt}, class REQUEST:\n\nhello$`));
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const err = join(ctx.runDir, "turns", "0001.stderr");
assert.equal(statSync(err).mode & 0o777, 0o600);
assert.equal(readFileSync(err, "utf8"), "fake adapter: fake-model\n");
assert.equal(statSync(join(ctx.runDir, "tools.sock")).mode & 0o777, 0o600);
r.child.kill("SIGTERM");
const out = await r.exited;
assert.equal(out.code, EXIT.stopped, out.stderr);
assert.match(out.stderr, /runner: demo\/coder claimed by run coder-run/);
assert.equal(ctx.store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, "release");
assert.ok(!existsSync(join(ctx.runDir, "tools.sock")));
});
// Row 109 (#1603): each turn reads the state when it starts. The fake adapter echoes the request,
// so a reply shows the block the turn was given.
const ask = async (ctx, body = "what now") => {
const before = ctx.inbox.length;
ctx.call(ctx.pm, "message.send", { to: "coder", body, class: "REQUEST" });
await until(() => replies(ctx)() && ctx.inbox.length > before);
return ctx.inbox.at(-1);
};
const snapshot = (i, over = {}, read = "2026-10-12T12:00:00.000Z") => ({
task_ref: `vikunja:3/${i}`,
updated: read,
digest: "d".repeat(64),
fields: { project_id: 3, title: `Task ${i}`, description: "", done: false, bucket: 11, ...over },
via: "board",
read_at: read,
});
test("launches revoked and then restored: the turn's block reads allowed and gives the restore time", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
ctx.call(ctx.human, "launch.revoke");
assert.match((await ask(ctx)).body, /\nLaunches: revoked since \S+ \(event \S+\)\.\n/);
ctx.call(ctx.human, "launch.restore");
const restored = ctx.store.get("SELECT id,at FROM events WHERE kind='launch.restored'");
assert.ok((await ask(ctx)).body.includes(`\nLaunches: allowed since ${restored.at} (event ${restored.id}).\n`));
});
test("a task closed on the tracker is missing from the next turn's open list", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
ctx.broker.recordTask({ business: "demo", snapshots: [snapshot(1), snapshot(2)] });
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const first = (await ask(ctx)).body;
assert.match(first, /\nOpen tasks: 2\.\n- vikunja:3\/1 Task 1\n- vikunja:3\/2 Task 2\n/);
ctx.broker.recordTask({ business: "demo", snapshots: [snapshot(2, { done: true }, "2026-10-12T13:00:00.000Z")] });
const second = (await ask(ctx)).body;
assert.match(second, /\nOpen tasks: 1\.\n- vikunja:3\/1 Task 1\n\nMessage /);
assert.ok(!second.includes("vikunja:3/2"));
});
test("a message read a day after it was sent shows the time it was sent", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "resume the launch", class: "REQUEST" });
const sentAt = ctx.store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
// The broker's clock jumps a day before the runner reads: the header keeps the original time.
const now = Date.now;
Date.now = () => now() + 86_400_000;
t.after(() => (Date.now = now));
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const [reply] = await until(replies(ctx));
assert.ok(reply.body.includes(`Message ${sent.id} from pm, sent ${sentAt}, class REQUEST:`));
assert.ok(Date.parse(reply.at) - Date.parse(sentAt) >= 86_000_000);
});
test("a failed state read fails the turn: no adapter runs, the sender hears why, the message is read", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const request = ctx.broker.request.bind(ctx.broker);
ctx.broker.request = (cap, req) => {
if (req.verb === "state") throw new BusError("state-unavailable");
return request(cap, req);
};
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "plan the week", class: "REQUEST" });
const [reply] = await until(replies(ctx));
assert.equal(reply.class, "RESULT");
assert.equal(reply.in_reply_to, sent.id);
assert.equal(reply.body, "The turn failed: could not read the current state: state-unavailable");
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
assert.ok(!existsSync(join(ctx.runDir, "turns", "0001.stderr")));
assert.match(r.stderr(), /turn 1 for message \S+ failed: could not read the current state: state-unavailable/);
});
test("a SIGTERM before the claim stops the runner with exit 0 and no claim", async (t) => {
const ctx = await setup(t);
// session.json becomes a FIFO: main opens it right after its handlers go
// in, so the open on this side returns once a SIGTERM would be caught.
const file = join(ctx.runDir, "session.json");
const body = readFileSync(file);
rmSync(file);
assert.equal(spawnSync("mkfifo", ["-m", "600", file]).status, 0);
const child = spawn(process.execPath, [RUNNER, ctx.runDir], { stdio: ["pipe", "ignore", "pipe"], env: { PATH: process.env.PATH } });
t.after(() => child.kill("SIGKILL"));
let stderr = "";
child.stderr.on("data", (b) => (stderr += b));
const exited = once(child, "exit");
const fifo = await open(file, "w");
child.kill("SIGTERM");
await fifo.write(body);
await fifo.close();
child.stdin.end(JSON.stringify({ cap: ctx.cap }) + "\n");
const [code, signal] = await exited;
assert.deepEqual([code, signal], [EXIT.stopped, null], stderr);
assert.match(stderr, /runner: stopped before claim/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});
test("typed tools carry the runner's capability; launch goes to the host's launch socket", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"pm","body":"side note","class":"INFO"}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
const [info, result] = ctx.inbox;
assert.equal(info.class, "INFO");
assert.equal(info.body, "side note");
assert.equal(info.from_run, "coder-run");
assert.equal(JSON.parse(result.body).id, info.id);
ctx.inbox.length = 0;
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL launch {"instance":"coder"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL launch {"instance":"reviewer"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
});
test("a RESULT gets no automatic reply; failed turns reply with the reason", async (t) => {
const ctx = await setup(t, { session: { turnTimeout: 1 } });
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const first = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hi" });
const res = ctx.call(ctx.pm, "message.send", { to: "coder", body: "thanks", class: "RESULT", in_reply_to: first.id });
ctx.call(ctx.pm, "message.send", { to: "coder", body: "FAIL" });
ctx.call(ctx.pm, "message.send", { to: "coder", body: "NOMARKER" });
ctx.call(ctx.pm, "message.send", { to: "coder", body: "SLEEP" });
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
assert.deepEqual(ctx.inbox.map((m) => m.body.replace(/^echo: .*/s, "echo")), [
"echo",
"The turn failed: adapter exited 3",
"The turn failed: pi exited before the turn ended (no agent_end)",
"The turn failed: turn passed its 1 s wall clock",
]);
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
});
test("SIGTERM during a turn kills the turn's process group and still exits 0", async (t) => {
// A long wall clock, so only the stop path can end the turn in time.
const ctx = await setup(t, { session: { turnTimeout: 600 } });
const r = startRunner(ctx.runDir, ctx.cap, { FAKE_PIDS: ctx.pids });
t.after(() => r.child.kill("SIGKILL"));
ctx.call(ctx.pm, "message.send", { to: "coder", body: "SLEEP" });
const pid = Number(await until(() => existsSync(ctx.pids) && readFileSync(ctx.pids, "utf8").trim()));
const started = Date.now();
r.child.kill("SIGTERM");
const out = await Promise.race([r.exited, new Promise((res) => setTimeout(() => res({ code: "still running" }), 8000))]);
assert.equal(out.code, EXIT.stopped, out.stderr);
assert.ok(Date.now() - started < 8000);
await until(() => {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
}, 8000);
});
test("founder credentials stop before the claim (20)", async (t) => {
const ctx = await setup(t);
const out = await startRunner(ctx.runDir, ctx.cap, { GH_TOKEN: "x" }).exited;
assert.equal(out.code, EXIT.founder);
assert.match(out.stderr, /REQ-CRED-2.*GH_TOKEN/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
const ctx2 = await setup(t, { policy: { needs: ["gitea"], credentials: [{ service: "gitea", state: "expired" }] } });
const out2 = await startRunner(ctx2.runDir, ctx2.cap).exited;
assert.equal(out2.code, EXIT.founder);
assert.match(out2.stderr, /no usable role token for gitea/);
});
test("a refused claim exits 21; an ended run's capability exits 22", async (t) => {
const ctx = await setup(t);
const other = ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "other-run", harness: "pi" });
ctx.call(other, "role.claim");
const out = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(out.code, EXIT.claim, out.stderr);
assert.match(out.stderr, /role.claim refused: role-already-held/);
const ctx2 = await setup(t);
ctx2.broker.endLaunch({ business: "demo", run: "coder-run", reason: "stopped", exitCode: 0 });
assert.equal((await startRunner(ctx2.runDir, ctx2.cap).exited).code, EXIT.session);
});
test("the launch ending under a running session exits 22", async (t) => {
const ctx = await setup(t);
const r = startRunner(ctx.runDir, ctx.cap);
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
ctx.broker.endLaunch({ business: "demo", run: "coder-run", reason: "killed", exitCode: null });
const out = await r.exited;
assert.equal(out.code, EXIT.session, out.stderr);
assert.match(out.stderr, /session stopped working: unauthenticated/);
});
test("a broker that stays unreachable exits 23 after brokerRetries polls", async (t) => {
const ctx = await setup(t, { session: { brokerRetries: 3 } });
const r = startRunner(ctx.runDir, ctx.cap);
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
await ctx.server.close();
const out = await r.exited;
assert.equal(out.code, EXIT.broker, out.stderr);
assert.match(out.stderr, /broker unreachable after 3 tries/);
});
test("a broker that is down at the claim exits 23, not 21", async (t) => {
const ctx = await setup(t);
await ctx.server.close();
const out = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(out.code, EXIT.broker, out.stderr);
assert.match(out.stderr, /role.claim got no answer from the broker/);
});
test("no capability, or a malformed one, on stdin exits 2", async (t) => {
const ctx = await setup(t);
assert.equal((await startRunner(ctx.runDir, null).exited).code, EXIT.usage);
assert.equal((await startRunner(ctx.runDir, "not-hex").exited).code, EXIT.usage);
assert.equal((await startRunner(join(ctx.dir, "nope"), ctx.cap).exited).code, EXIT.usage);
// A valid capability inside an over-long line: the 4096-byte cap refuses it.
const long = await startRunner(ctx.runDir, ctx.cap, {}, JSON.stringify({ cap: ctx.cap, pad: "x".repeat(5000) }) + "\n").exited;
assert.equal(long.code, EXIT.usage, long.stderr);
assert.match(long.stderr, /stdin too large/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});
test("a missing or malformed policy exits 2 before the claim", async (t) => {
const ctx = await setup(t);
const policy = join(ctx.runDir, "bundle", "policy.json");
writeFileSync(policy, "{");
const bad = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(bad.code, EXIT.usage, bad.stderr);
assert.match(bad.stderr, /^runner: /m);
rmSync(policy);
const missing = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(missing.code, EXIT.usage, missing.stderr);
assert.match(missing.stderr, /runner: ENOENT/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});