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, packet875c515e9. Filbert approved round 3, comment 28333, candidate manifest 0a62225e (11 paths). Sage gate on9774d70dbplus 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]>
444 lines
22 KiB
JavaScript
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);
|
|
});
|