" \+ \(rec\.workspace \? esc\(rec\.workspace\) : "unknown"\)/, "detail lists the workspace, escaped, or unknown");
- assert.match(body, /var span = showProject \? 7 : 6/, "the detail row spans the two new columns");
- const heads = html.match(/| Task<\/th> | Active project<\/th>/g) || [];
- assert.equal(heads.length, 3, "all three tables carry the two new headers");
- assert.match(html, / | No agents\.<\/td>/, "the empty project row spans every column");
-});
-
-// ---------------------------------------------------------------------------
-// 9. page.html: registration source tags and the Registered detail row
-// ---------------------------------------------------------------------------
-
-test("page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped", () => {
- const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
-
- const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
- assert.ok(rowPair, "buildRowPair() must exist in page.html");
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
- assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
- assert.match(
- rowPair[0],
- /Registered<\/dt>" \+ registeredText\(rec\.registered\) \+ "<\/dd>/,
- "the detail list has a Registered row built by registeredText()",
- );
-
- assert.match(rowPair[0], /esc\(rec\.agent\) \+ "<\/button>" \+ modelTag\(rec\)/, "the agent cell shows the model under the name");
- assert.match(rowPair[0], /Model<\/dt>" \+ modelText\(rec\) \+ "<\/dd>/, "the detail list has a Model row");
- for (const name of ["modelTag", "modelText"]) {
- const line = html.split("\n").find((l) => l.includes(`function ${name}(rec)`));
- assert.ok(line, `${name}() must exist in page.html`);
- assert.match(line, /esc\(rec\.model\)/, `${name}() escapes the model`);
- if (name === "modelText") assert.match(line, /esc\(rec\.provider\)/, "modelText() escapes the provider");
- }
-
- const sourceLabelMatch = html.match(/var SOURCE_LABEL = (\{.*\});/);
- assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
- const sourceLabel = JSON.parse(sourceLabelMatch[1]);
- assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
-
- // sourceTag() and fromSource() are one-liners in page.html; match them by
- // line rather than by a "function ... { ... \n }" block regex, which
- // assumes a closing brace on its own line.
- const lines = html.split("\n");
- const sourceTagLine = lines.find((l) => l.includes("function sourceTag(source)"));
- assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
- assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
- assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
-
- const fromSourceLine = lines.find((l) => l.includes("function fromSource(source)"));
- assert.ok(fromSourceLine, "fromSource() must exist in page.html");
- assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
-
- const registeredTextFn = html.match(/function registeredText\(reg\) \{[\s\S]*?\n \}/);
- assert.ok(registeredTextFn, "registeredText() must exist in page.html");
- for (const line of registeredTextFn[0].split("\n")) {
- if (!/\breg\.[a-zA-Z]/.test(line)) continue;
- assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
- }
-});
-
-// ---------------------------------------------------------------------------
-// 10. POST /api/reply: reply-from-board through a fake agent-send.sh (#1505)
+// 3. createBoard().reply: the reply policy, in-process (row 198). The HTTP
+// route is the Console's now; its tests live in packages/webui/tests.
// ---------------------------------------------------------------------------
// A fake tools/tmux/agent-send.sh: records argv and the env it saw, exits
@@ -729,373 +178,12 @@ function replyFixture({ tmux = { socket: null, session: "agent1" }, pid = proces
specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] };
}
-async function postReply(base, body, headers = { "content-type": "application/json" }) {
- const res = await fetch(`${base}/api/reply`, { method: "POST", headers, body: typeof body === "string" ? body : JSON.stringify(body) });
- return { status: res.status, body: await res.json() };
-}
-
-test("POST /api/reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams", async () => {
- const f = replyFixture();
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z") });
- const base = `http://127.0.0.1:${server.address().port}`;
- process.env.MOSAIC_TMUX_SOCKET = "leaked-from-launcher";
- delete process.env.FAKE_SEND_EXIT;
- delete process.env.FAKE_SEND_STDERR;
- try {
- const { status, body } = await postReply(base, { agent: "proj/agent1", text: "yes, go ahead" });
- assert.equal(status, 200);
- assert.equal(body.delivered, true);
- assert.equal(body.exitCode, 0);
- assert.equal(body.stdout, "fake sent\n");
- assert.equal(body.stderr, "");
- assert.equal(body.session, "agent1");
- assert.equal(body.socket, null);
- assert.equal(body.sentAt, "2026-09-12T18:00:00.000Z");
- const seen = JSON.parse(readFileSync(f.capture, "utf8"));
- assert.equal(seen.tmuxSocketEnv, null, "the launcher's socket variable must not reach the tool");
- const argv = seen.argv;
- assert.equal(argv[argv.indexOf("-s") + 1], "agent1");
- assert.match(argv[argv.indexOf("-S") + 1], /^[^:\s]+:control-board$/);
- assert.equal(argv[argv.indexOf("-m") + 1], "yes, go ahead\n" + REPLY_TRAILER, "the text, then the fixed trailer on its own line");
- assert.match(REPLY_TRAILER, /^\(control-board: .*Do not agent-send to control-board\.\)$/);
- assert.equal(argv.includes("-L"), false);
- assert.equal(argv.length, 6, "exactly -s, -S and -m: no send-keys, class or retries");
- } finally {
- delete process.env.MOSAIC_TMUX_SOCKET;
- await closeServer(server);
- }
-});
-
-test("POST /api/reply: a registration with a tmux socket adds -L ", async () => {
- const f = replyFixture({ tmux: { socket: "mosaic-fleet", session: "agent1" } });
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend });
- const base = `http://127.0.0.1:${server.address().port}`;
- try {
- const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" });
- assert.equal(status, 200);
- assert.equal(body.socket, "mosaic-fleet");
- const argv = JSON.parse(readFileSync(f.capture, "utf8")).argv;
- assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
- } finally {
- await closeServer(server);
- }
-});
-
-test("POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim", async () => {
- const f = replyFixture();
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend });
- const base = `http://127.0.0.1:${server.address().port}`;
- process.env.FAKE_SEND_EXIT = "4";
- process.env.FAKE_SEND_STDERR = "agent-send.sh: REFUSING - session 'agent1' exists on multiple sockets\n";
- try {
- const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" });
- assert.equal(status, 200);
- assert.equal(body.delivered, false);
- assert.equal(body.exitCode, 4);
- assert.match(body.stderr, /REFUSING/);
- } finally {
- delete process.env.FAKE_SEND_EXIT;
- delete process.env.FAKE_SEND_STDERR;
- await closeServer(server);
- }
-});
-
-test("POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called", async () => {
- const cases = [
- { fixture: replyFixture(), body: { agent: "proj/agent1", text: "" }, status: 400, error: /non-empty/ },
- { fixture: replyFixture(), body: { agent: "proj/agent1", text: " \n" }, status: 400, error: /non-empty/ },
- { fixture: replyFixture(), body: { agent: "proj/agent1", text: "x".repeat(REPLY_LIMIT + 1) }, status: 400, error: /longer than/ },
- { fixture: replyFixture(), body: { agent: "proj/nobody", text: "hi" }, status: 404, error: /unknown row/ },
- { fixture: replyFixture(), body: { text: "hi" }, status: 400, error: /row id/ },
- { fixture: replyFixture({ registered: false }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /needs a registered seat/ },
- { fixture: replyFixture({ pid: 4242 }), isPidAlive: () => false, body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /stale/ },
- { fixture: replyFixture({ tmux: null }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /no tmux session/ },
- { fixture: replyFixture(), body: "{not json", status: 400, error: /invalid JSON/ },
- ];
- for (const c of cases) {
- const f = c.fixture;
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend, isPidAlive: c.isPidAlive });
- const base = `http://127.0.0.1:${server.address().port}`;
- try {
- const { status, body } = await postReply(base, c.body);
- assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
- assert.match(body.error, c.error);
- assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
- } finally {
- await closeServer(server);
- }
- }
-});
-
-test("POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash", async () => {
- const f = replyFixture();
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: join(f.root, "missing-agent-send.sh") });
- const base = `http://127.0.0.1:${server.address().port}`;
- try {
- const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" });
- assert.equal(status, 500);
- assert.match(body.error, /missing-agent-send\.sh/);
- const health = await fetch(`${base}/healthz`);
- assert.equal(health.status, 200);
- } finally {
- await closeServer(server);
- }
-});
-
test("replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable", () => {
assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
assert.ok(existsSync(DEFAULT_AGENT_SEND));
assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
});
-test("page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped", () => {
- const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
- const lines = html.split("\n");
- const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
- assert.ok(canReplyFn, "canReply() must exist");
- assert.match(canReplyFn[0], /reg\.alive !== false/, "a stale registration gets no box");
- assert.match(canReplyFn[0], /reg\.tmux && reg\.tmux\.session/, "a registration without a tmux session gets no box");
- const replyControlFn = html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/);
- assert.ok(replyControlFn, "replyControl() must exist");
- assert.match(replyControlFn[0], /if \(!canReply\(rec\)\) return 'reply needs a registered seat<\/span>'/);
- assert.match(replyControlFn[0], /class="reply-form" data-id="' \+ esc\(id\)/);
- assert.match(replyControlFn[0], /value="' \+ esc\(replyDrafts\[id\] \|\| ""\)/, "an unsent draft is put back after a refresh");
- const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
- assert.match(rowPair[0], /Reply<\/dt>" \+ replyControl\(rec\) \+ "<\/dd>/);
- const receiptFn = html.match(/function replyReceipt\(id\) \{[\s\S]*?\n \}/);
- assert.ok(receiptFn, "replyReceipt() must exist");
- for (const line of receiptFn[0].split("\n")) {
- if (!/\br\.[a-zA-Z]/.test(line)) continue;
- assert.match(line, /esc\(/, `every receipt value must be escaped: ${line.trim()}`);
- }
- assert.match(receiptFn[0], /esc\(r\.stderr \|\| r\.error/, "a failure shows the tool's stderr");
- const postReplyFn = html.match(/function postReply\(form\) \{[\s\S]*?\n \}/);
- assert.ok(postReplyFn, "postReply() must exist");
- assert.match(postReplyFn[0], /fetch\("\/api\/reply", \{\s*method: "POST",\s*headers: \{ "content-type": "application\/json" \}/);
- assert.match(postReplyFn[0], /JSON\.stringify\(\{ agent: id, text: text \}\)/);
- assert.match(postReplyFn[0], /replyReceipts\[id\] = body/);
- assert.ok(lines.some((l) => l.includes('main.addEventListener("submit"')), "the form submits through one delegated handler");
- assert.ok(lines.some((l) => l.includes('main.addEventListener("input"')), "drafts are captured as they are typed");
- assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
- assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
-});
-
-// ---------------------------------------------------------------------------
-// Task attribution (#1511): serialized by the server, rendered by page.html.
-// ---------------------------------------------------------------------------
-
-test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => {
- const root = makeRoot();
- const sessionsDir = join(root, "sessions");
- const derivedDir = join(root, "derived-sessions");
- for (const dir of [sessionsDir, derivedDir]) {
- writeSessionFile(dir, "s.jsonl", [
- sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
- messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
- ]);
- }
- const seatsDir = join(root, "seats");
- const reg = makeRegistration({
- resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
- task: "registered task",
- });
- writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" });
- const specs = [
- { agent: "agent1", project: "proj", sessionsDir, tmux: {} },
- { agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} },
- ];
- const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "" });
- try {
- const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json();
- const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r]));
- assert.equal(byAgent.agent1.taskSource, "registration");
- assert.equal(byAgent.agent1.taskSetBy, "darkwing");
- assert.equal(byAgent.agent2.taskSource, "first-user-message");
- assert.equal(byAgent.agent2.taskSetBy, null);
- assert.deepEqual(body.registrationErrors, []);
- } finally {
- await new Promise((r) => server.close(r));
- }
-});
-
-test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => {
- const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
- const lines = html.split("\n");
- const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
- assert.ok(rowPair);
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
- assert.match(rowPair[0], /Task set by<\/dt>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
- const tagLine = lines.find((l) => l.includes("function setByTag(rec)"));
- assert.ok(tagLine);
- assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
- assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
- assert.match(tagLine, /not verified/);
- const textLine = lines.find((l) => l.includes("function setByText(rec)"));
- assert.ok(textLine);
- assert.match(textLine, /esc\(rec\.taskSetBy\)/);
- assert.match(textLine, /not verified/);
- // Attribution never enables a reply: canReply() reads the registration only.
- const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
- assert.ok(canReplyFn);
- assert.equal(canReplyFn[0].includes("taskSetBy"), false);
- assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
-});
-
-// ---------------------------------------------------------------------------
-// 11. Host and Origin guard (#1507). Binding to loopback does not stop a page
-// whose DNS name was rebound to 127.0.0.1: the browser then treats this
-// server as that page's own origin and sends its own name as Host. Every
-// route refuses a Host that is not a loopback name on this port, and any
-// Origin other than this server's own. Same check as the WebUI server.
-// ---------------------------------------------------------------------------
-
-// fetch() will not send a chosen Host header, so these requests use node:http.
-function rawRequest(port, { method = "GET", path = "/api/board", headers = {}, body = null }) {
- return new Promise((resolvePromise, reject) => {
- const req = httpRequest({ host: "127.0.0.1", port, method, path, headers, setHost: false }, (res) => {
- const chunks = [];
- res.on("data", (c) => chunks.push(c));
- res.on("end", () => resolvePromise({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString("utf8") }));
- });
- req.on("error", reject);
- req.end(body ?? undefined);
- });
-}
-
-test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers", async () => {
- const f = replyFixture();
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend });
- const port = server.address().port;
- const own = `127.0.0.1:${port}`;
- const reply = JSON.stringify({ agent: "proj/agent1", text: "rebound page" });
- const cases = [
- ["foreign Host", { host: `rebind.example:${port}` }, "non-local Host refused"],
- ["loopback Host, wrong port", { host: `127.0.0.1:${port + 1}` }, "non-local Host refused"],
- ["Host with credentials", { host: `x@${own}` }, "non-local Host refused"],
- ["cross-origin Origin", { host: own, origin: "http://rebind.example" }, "cross-origin request refused"],
- ["opaque Origin", { host: own, origin: "null" }, "cross-origin request refused"],
- ];
- try {
- for (const [label, headers, error] of cases) {
- const board = await rawRequest(port, { headers });
- assert.equal(board.status, 403, `GET /api/board, ${label}`);
- assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`);
- assert.equal(board.headers["access-control-allow-origin"], undefined);
- const posted = await rawRequest(port, { method: "POST", path: "/api/reply", headers: { ...headers, "content-type": "application/json" }, body: reply });
- assert.equal(posted.status, 403, `POST /api/reply, ${label}`);
- assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`);
- assert.equal(posted.headers["access-control-allow-origin"], undefined);
- }
- // Node's HTTP server answers an HTTP/1.1 request with no Host 400 before the handler runs.
- assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host");
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host");
- assert.equal(existsSync(f.capture), false, "agent-send was never run");
- assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board");
- for (const path of ["/", "/healthz"]) {
- assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path);
- }
- // Refusals come first on the other routes too: no method or body handling.
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403);
- } finally {
- await closeServer(server);
- }
-});
-
-test("Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin", async () => {
- const f = replyFixture();
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend });
- const port = server.address().port;
- delete process.env.FAKE_SEND_EXIT;
- delete process.env.FAKE_SEND_STDERR;
- try {
- for (const host of [`127.0.0.1:${port}`, `localhost:${port}`, `LOCALHOST:${port}`, `[::1]:${port}`]) {
- assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host);
- assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`);
- }
- // The board's own page posts with its own Origin; the WebUI proxy (Node fetch) sends none.
- const own = `127.0.0.1:${port}`;
- const posted = await rawRequest(port, { method: "POST", path: "/api/reply", headers: { host: own, origin: `http://${own}`, "content-type": "application/json" }, body: JSON.stringify({ agent: "proj/agent1", text: "same origin" }) });
- assert.equal(posted.status, 200);
- assert.equal(JSON.parse(posted.text).delivered, true);
- assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin");
- } finally {
- await closeServer(server);
- }
-});
-
-// ---------------------------------------------------------------------------
-// 12. Read-only conversation routes (#1507, CHAT-02 D3): GET /api/conversations
-// and GET /api/conversation, served from packages/conversation. Same Host and
-// Origin guard as every route (F16), application/json with nosniff and
-// no-store, refusals mapped to 4xx with their code, and nothing written.
-// ---------------------------------------------------------------------------
-
-function conversationFixture() {
- const root = makeRoot();
- const proj = join(root, "proj");
- const sessionsDir = join(proj, ".pi", "state", "agent1", "sessions");
- const header = { type: "session", version: 3, id: "c0ffee00-0000-4000-8000-000000000001", timestamp: "2026-09-26T12:00:00.000Z", cwd: proj };
- const lines = [header];
- for (let i = 0; i < 120; i++) {
- lines.push({ type: "message", id: `e${i}`, parentId: i ? `e${i - 1}` : null, timestamp: new Date(Date.parse("2026-09-26T12:00:01Z") + i * 1000).toISOString(), message: { role: i % 2 ? "assistant" : "user", content: [{ type: "text", text: `m${i}` }] } });
- }
- writeSessionFile(sessionsDir, "s.jsonl", lines.map((l) => JSON.stringify(l)));
- const outside = join(root, "outside.jsonl");
- writeFile(outside, JSON.stringify({ ...header, cwd: "/elsewhere" }) + "\n");
- chmodSync(outside, 0o000);
- symlinkSync(outside, join(sessionsDir, "link.jsonl"));
- const seatsDir = join(root, "seats");
- writeRegistration(seatsDir, makeRegistration({
- resolved: { seat: "rocko", project: "proj", sessionsDir: join(proj, ".pi", "state", "rocko", "sessions"), seatDir: join(root, "rocko"), launchScript: join(root, "rocko", "launch.sh"), layout: "repo", defaultWorkspace: null },
- harness: "claude-code",
- }));
- return { root, proj, sessionsDir, seatsDir, boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] };
-}
-
-// Size, sha256, mtime, (dev, ino) and listing of every entry under dir.
-function treePrint(dir) {
- const out = {};
- for (const name of readdirSync(dir).sort()) {
- const path = join(dir, name);
- const st = lstatSync(path, { bigint: true });
- out[name] = [String(st.dev), String(st.ino), String(st.size), String(st.mtimeNs), st.isFile() ? createHashHex(readFileSync(path)) : null];
- }
- return out;
-}
-function createHashHex(buf) {
- return spawnSync("sha256sum", { input: buf, encoding: "utf8" }).stdout.split(" ")[0];
-}
-
-test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers", async () => {
- const f = conversationFixture();
- const calls = [];
- const spy = { catalogue: () => calls.push("catalogue"), open: () => calls.push("open"), next: () => calls.push("next") };
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", conversationReader: spy });
- const port = server.address().port;
- const own = `127.0.0.1:${port}`;
- const cases = [
- ["foreign Host", { host: `rebind.example:${port}` }, "non-local Host refused"],
- ["loopback Host, wrong port", { host: `127.0.0.1:${port + 1}` }, "non-local Host refused"],
- ["cross-origin Origin", { host: own, origin: "http://rebind.example" }, "cross-origin request refused"],
- ["opaque Origin", { host: own, origin: "null" }, "cross-origin request refused"],
- ];
- try {
- for (const path of ["/api/conversations", "/api/conversation?id=pi-00000000000000000000000000000000", "/api/conversation?id=x&cursor=c-1"]) {
- for (const [label, headers, error] of cases) {
- const r = await rawRequest(port, { path, headers });
- assert.equal(r.status, 403, `${path}, ${label}`);
- assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
- for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
- }
- }
- assert.deepEqual(calls, [], "the reader never ran for a refused request");
- assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
- assert.deepEqual(calls, ["catalogue"]);
- } finally {
- await closeServer(server);
- }
-});
-
test("every refusal code the reader can raise has an HTTP status", () => {
// Every source file in the reader package, so a refusal added in a new file is seen too.
const dir = join(pkgRoot, "..", "conversation", "src");
@@ -1114,63 +202,88 @@ test("every refusal code the reader can raise has an HTTP status", () => {
});
});
-test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => {
- const f = conversationFixture();
- const before = treePrint(f.sessionsDir);
- const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "" });
- const base = `http://127.0.0.1:${server.address().port}`;
- const get = async (path) => {
- const res = await fetch(base + path);
- assert.equal(res.headers.get("content-type"), "application/json", path);
- assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
- assert.equal(res.headers.get("cache-control"), "no-store", path);
- assert.equal(res.headers.get("access-control-allow-origin"), null, path);
- return { status: res.status, body: await res.json() };
- };
+// The Console's reply route calls board.reply() with the parsed body, so these pin the policy it inherits.
+function replyBoard(f, agentSend = f.agentSend) {
+ return createBoard({ specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, agentSend, now: () => new Date("2026-09-12T18:00:00Z") });
+}
+
+test("reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams", async () => {
+ const f = replyFixture();
+ process.env.MOSAIC_TMUX_SOCKET = "leaked-from-launcher";
+ delete process.env.FAKE_SEND_EXIT;
+ delete process.env.FAKE_SEND_STDERR;
try {
- const cat = await get("/api/conversations");
- assert.equal(cat.status, 200);
- const rows = cat.body.conversations;
- const pi = rows.find((c) => c.availability === "available");
- const link = rows.find((c) => c.availability === "denied");
- const claude = rows.find((c) => c.availability === "unsupported");
- assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
- assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
-
- const first = await get(`/api/conversation?id=${pi.conversation}`);
- assert.equal(first.status, 200);
- assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
- const q = (cursor) => `/api/conversation?id=${pi.conversation}&branch=${first.body.page.branch}&cursor=${cursor}`;
- const second = await get(q(first.body.page.nextCursor));
- assert.equal(second.status, 200);
- assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
- assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
- const follow = await get(q(second.body.follow.id));
- assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
- const branch = await get(`/api/conversation?id=${pi.conversation}&branch=main`);
- assert.equal(branch.status, 200);
-
- const refusals = [
- [`/api/conversation?id=pi-${"0".repeat(32)}`, 404, "unknown-conversation", true],
- [`/api/conversation?id=${pi.conversation}&branch=b.e5`, 404, "unknown-branch", true],
- [q("c-unknown"), 409, "cursor-unknown", true],
- [`/api/conversation?id=${pi.conversation}&branch=b.e1&cursor=${first.body.page.nextCursor}`, 409, "cursor-foreign", true],
- [`/api/conversation?id=${link.conversation}`, 403, "unsafe-path", false],
- [`/api/conversation?id=${claude.conversation}`, 422, "unsupported-harness", false],
- ];
- for (const [path, status, code, reconcile] of refusals) {
- const r = await get(path);
- assert.equal(r.status, status, path);
- assert.deepEqual(r.body.refusal, { code, reconcile }, path);
- assert.equal(typeof r.body.error, "string");
- }
- for (const path of ["/api/conversation", "/api/conversation?id=a&id=b", "/api/conversation?id=a&path=/etc/passwd", "/api/conversation?id=a&path=x", `/api/conversation?id=${pi.conversation}&cursor=${second.body.follow.id}`, "/api/conversation?id=../x", `/api/conversation?id=${"a".repeat(129)}`, "/api/conversations?x=1"]) {
- const r = await get(path);
- assert.equal(r.status, 400, path);
- }
- assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
- assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
+ const { status, body } = await replyBoard(f).reply({ agent: "proj/agent1", text: "yes, go ahead" });
+ assert.equal(status, 200);
+ assert.equal(body.delivered, true);
+ assert.equal(body.exitCode, 0);
+ assert.equal(body.stdout, "fake sent\n");
+ assert.equal(body.stderr, "");
+ assert.equal(body.session, "agent1");
+ assert.equal(body.socket, null);
+ assert.equal(body.sentAt, "2026-09-12T18:00:00.000Z");
+ const seen = JSON.parse(readFileSync(f.capture, "utf8"));
+ assert.equal(seen.tmuxSocketEnv, null, "the launcher's socket variable must not reach the tool");
+ const argv = seen.argv;
+ assert.equal(argv[argv.indexOf("-s") + 1], "agent1");
+ assert.match(argv[argv.indexOf("-S") + 1], /^[^:\s]+:control-board$/);
+ assert.equal(argv[argv.indexOf("-m") + 1], "yes, go ahead\n" + REPLY_TRAILER, "the text, then the fixed trailer on its own line");
+ assert.match(REPLY_TRAILER, /^\(control-board: .*Do not agent-send to control-board\.\)$/);
+ assert.equal(argv.includes("-L"), false);
+ assert.equal(argv.length, 6, "exactly -s, -S and -m: no send-keys, class or retries");
} finally {
- await closeServer(server);
+ delete process.env.MOSAIC_TMUX_SOCKET;
}
});
+
+test("reply: a registration with a tmux socket adds -L ", async () => {
+ const f = replyFixture({ tmux: { socket: "mosaic-fleet", session: "agent1" } });
+ const { status, body } = await replyBoard(f).reply({ agent: "proj/agent1", text: "hi" });
+ assert.equal(status, 200);
+ assert.equal(body.socket, "mosaic-fleet");
+ const argv = JSON.parse(readFileSync(f.capture, "utf8")).argv;
+ assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
+});
+
+test("reply: a non-zero tool exit is delivered false, with the exit code and the stderr verbatim", async () => {
+ const f = replyFixture();
+ process.env.FAKE_SEND_EXIT = "4";
+ process.env.FAKE_SEND_STDERR = "agent-send.sh: REFUSING - session 'agent1' exists on multiple sockets\n";
+ try {
+ const { status, body } = await replyBoard(f).reply({ agent: "proj/agent1", text: "hi" });
+ assert.equal(status, 200);
+ assert.equal(body.delivered, false);
+ assert.equal(body.exitCode, 4);
+ assert.match(body.stderr, /REFUSING/);
+ } finally {
+ delete process.env.FAKE_SEND_EXIT;
+ delete process.env.FAKE_SEND_STDERR;
+ }
+});
+
+test("reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, missing row id 400, no registration 409, no tmux session 409; the tool is never called", async () => {
+ const cases = [
+ { fixture: replyFixture(), body: { agent: "proj/agent1", text: "" }, status: 400, error: /non-empty/ },
+ { fixture: replyFixture(), body: { agent: "proj/agent1", text: " \n" }, status: 400, error: /non-empty/ },
+ { fixture: replyFixture(), body: { agent: "proj/agent1", text: "x".repeat(REPLY_LIMIT + 1) }, status: 400, error: /longer than/ },
+ { fixture: replyFixture(), body: { agent: "proj/nobody", text: "hi" }, status: 404, error: /unknown row/ },
+ { fixture: replyFixture(), body: { text: "hi" }, status: 400, error: /row id/ },
+ { fixture: replyFixture({ registered: false }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /needs a registered seat/ },
+ { fixture: replyFixture({ tmux: null }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /no tmux session/ },
+ ];
+ for (const c of cases) {
+ const f = c.fixture;
+ const { status, body } = await replyBoard(f).reply(c.body);
+ assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
+ assert.match(body.error, c.error);
+ assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
+ }
+});
+
+test("reply: a missing agent-send.sh is a 500 with the path in the error, not a crash", async () => {
+ const f = replyFixture();
+ const { status, body } = await replyBoard(f, join(f.root, "missing-agent-send.sh")).reply({ agent: "proj/agent1", text: "hi" });
+ assert.equal(status, 500);
+ assert.match(body.error, /missing-agent-send\.sh/);
+});
+
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
index 300e239a0..ef906fe61 100644
--- a/packages/webui/src/cli.mjs
+++ b/packages/webui/src/cli.mjs
@@ -1,7 +1,9 @@
#!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
-import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
+import { startServer, previewOptions, proofOptions } from './serve.mjs';
+import { createBoard } from '../../control-board/src/board.mjs';
+import { discoverRepoAgents, discoverFleetAgents } from '../../control-board/src/scan.mjs';
import { busCaller } from './bus.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
@@ -11,7 +13,8 @@ import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
-import { isAbsolute } from 'node:path';
+import { homedir } from 'node:os';
+import { isAbsolute, join } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
// agentWrite reaches only these.
@@ -22,11 +25,11 @@ const agentCaller = (call, verbs) => async (verb, args) => {
return call(verb, args);
};
const args = process.argv.slice(2);
-const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
+const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
- const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
+ const options = { port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
@@ -36,12 +39,14 @@ try {
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
+ // Row 198: the board runs inside the Console; there is no board origin to point at.
+ if (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
- } else if (flag === '--board') options.board = value;
+ }
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
else if (flag === '--checkout') {
@@ -99,6 +104,13 @@ try {
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
+ // Row 198: the board runs in-process over the system config's data root and reads the same repository
+ // agents as before, plus the fleet's read-only specs (the old board's default). Without the system
+ // config there is no data root, so the board routes answer not-configured.
+ const board = system ? createBoard({
+ specs: [...discoverRepoAgents(options.checkout ?? REPO), ...discoverFleetAgents(join(homedir(), '.mosaic', 'fleet', 'agents'))],
+ boardDir: join(system.dataRoot, 'board'), seatsDir: join(system.dataRoot, 'seats'), discordDataRoot: system.dataRoot,
+ }) : null;
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
@@ -110,14 +122,14 @@ try {
}
let server;
try {
- server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
+ server = await startServer({ board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
- testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
+ testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
@@ -136,7 +148,7 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
- console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
+ console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${board ? 'in the Console (no port)' : 'not configured (no system config)'}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index 9739c9a8f..0bddd5905 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -7,7 +7,7 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer;
- let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
+ let board = 'the Console board', palette = 'harbor', mode = 'system', failed = false;
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false;
@@ -476,5 +476,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
- api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
+ api('/api/config').then(config => { board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 288871d0c..5a20a40ad 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -11,19 +11,9 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
-export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
}
-export function boardURL(value) {
- const url = new URL(value);
- if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
- throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
- }
- // Avoid hostname resolution for localhost.
- if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
- return url.origin;
-}
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
@@ -169,6 +159,20 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
+// Row 198: the board's writes, in-process. A body the board refuses is a 400; the reply's own answer
+// (its status and receipt) comes back as the board gave it, and a failed scan is a 500 with no detail.
+async function boardSend(req, res, { board, path }) {
+ let value;
+ try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return json(res, 400, { error: err.message }); }
+ try {
+ if (path === '/api/seen') return json(res, 200, await board.markSeen(value));
+ const out = await board.reply(value);
+ return json(res, out.status, out.body);
+ } catch (err) {
+ if (path === '/api/seen') return json(res, 400, { error: err.message });
+ return json(res, 500, { error: 'board read failed' });
+ }
+}
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
let to = null;
@@ -579,7 +583,8 @@ async function projectFileRead(res, read, path, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
+// `board` is createBoard() from packages/control-board (row 198), run in-process; null answers the board routes 503.
+export async function startServer({ host = '127.0.0.1', port = 7330, board = null, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -590,13 +595,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
- const upstream = boardURL(board);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
+ const boardLabel = board ? 'in the Console' : null;
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
@@ -708,30 +713,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
+ // Row 198: the board's two writes run in-process, under --send and the public name only.
+ if (path === '/api/seen' || path === '/api/reply') {
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
+ if (!send || !named) return json(res, 403, { error: 'read-only', sent: false });
+ if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
+ return boardSend(req, res, { board, path });
+ }
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
- // The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
- const conversation = path === '/api/conversations' || path === '/api/conversation';
- const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
- if (allowed) {
- if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
- let bytes;
- if (allowed === 'POST') {
- try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
- }
- try {
- const response = await fetch(upstream + path + (conversation ? search : ''), {
- method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
- redirect: 'error', signal: AbortSignal.timeout(timeout),
- });
- const text = await response.text();
- JSON.parse(text); // Never serve upstream HTML or scripts as API data.
- res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
- return res.end(text);
- } catch {
- return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
- }
- }
if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
@@ -751,7 +741,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
- if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
+ if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: boardLabel, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
@@ -761,9 +751,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return consoleRead(res, () => reads.row(Number(id)));
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
+ // Row 198: the board's reads, in-process. The sign-in gate above has already run for each one.
+ if (path === '/api/board' || path === '/api/conversations' || path === '/api/conversation') {
+ if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
+ if (path === '/api/board') return consoleRead(res, () => board.index());
+ let out;
+ try { out = board.conversation(new URL(req.url, 'http://localhost')); } catch { return json(res, 500, { error: 'conversation read failed' }); }
+ return json(res, out.status, out.body);
+ }
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
- if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
+ if (path === '/api/config') return json(res, 200, { board: boardLabel, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs
index 588024b3a..09457674f 100644
--- a/packages/webui/tests/board-held.test.mjs
+++ b/packages/webui/tests/board-held.test.mjs
@@ -72,7 +72,7 @@ async function setup(machine, fast = false) {
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine });
- const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
+ const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs
index 1cb973169..2b0a491da 100644
--- a/packages/webui/tests/browser.test.mjs
+++ b/packages/webui/tests/browser.test.mjs
@@ -109,12 +109,12 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#close").click()');
}
// Empty and unreachable pages are tested as browser responses, never live data claims.
- // Stop only the fixture board. The page keeps the last data and reports its URL.
- await new Promise(r => f.board.close(r));
+ // Fail only the fixture board's reads. The page keeps the last data and names the board it could not reach (row 198: the Console).
+ f.board.setDown(true);
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#error").hidden');
const error = await b.evaluate('document.querySelector("#error").textContent');
- assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
+ assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
assert.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
index bf09119f1..713d526cd 100644
--- a/packages/webui/tests/bus-browser.test.mjs
+++ b/packages/webui/tests/bus-browser.test.mjs
@@ -98,7 +98,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.ok(comments.label.includes('\u001b'), 'the label excerpt keeps the ESC byte');
assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character');
writeFileSync(fieldsFile, JSON.stringify(hostile));
- const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
+ const web = await startServer({ port: 0, board: f.board, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index f3eeeca96..11365ff28 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -41,7 +41,7 @@ const stub = `(() => {
})();`;
async function setup(t, suffix = '') {
const f = await fixture(); t.after(() => f.close());
- const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
+ const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 8c11611cf..d004f998f 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -294,7 +294,7 @@ test('configured Console process injects chat through its existing human transpo
const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
- const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
+ const child = spawn(process.execPath, [cli, 'serve', '--port', '0',
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
diff --git a/packages/webui/tests/discord.test.mjs b/packages/webui/tests/discord.test.mjs
index ef9851ed2..d0711668e 100644
--- a/packages/webui/tests/discord.test.mjs
+++ b/packages/webui/tests/discord.test.mjs
@@ -3,16 +3,15 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
-import { close } from './fixture.mjs';
+import { close, inProcessBoard } from './fixture.mjs';
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
- let board, web, b;
+ let web, b;
try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
@@ -23,8 +22,8 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
- board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
- web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
+ const board = inProcessBoard({ root, specs: [], seatsDir, exec: () => { throw Error('no transport'); } });
+ web = await startServer({ port: 0, board });
const base = `http://127.0.0.1:${web.address().port}`;
const payload = await (await fetch(base + '/api/board')).json();
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
@@ -35,7 +34,7 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
- await b.navigate(`http://127.0.0.1:${board.address().port}`);
+ await b.navigate(base);
await wait('document.querySelector(".session-row")');
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
@@ -58,5 +57,5 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
- } finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
+ } finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
diff --git a/packages/webui/tests/docs-browser.test.mjs b/packages/webui/tests/docs-browser.test.mjs
index 77c192b3e..ca54b8783 100644
--- a/packages/webui/tests/docs-browser.test.mjs
+++ b/packages/webui/tests/docs-browser.test.mjs
@@ -48,7 +48,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
const f = await fixture(), b = await browser();
const reads = { files: [], file: [] };
const web = await startServer({
- port: 0, board: f.boardURL,
+ port: 0, board: f.board,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async args => { reads.files.push(args); return { head: C1, files: Object.keys(files).map(path => ({ path })) }; },
projectFile: async args => { reads.file.push(args); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
diff --git a/packages/webui/tests/editor-browser.test.mjs b/packages/webui/tests/editor-browser.test.mjs
index 869c74db9..dc43fd4ad 100644
--- a/packages/webui/tests/editor-browser.test.mjs
+++ b/packages/webui/tests/editor-browser.test.mjs
@@ -51,7 +51,7 @@ async function session(run) {
const reads = [];
const f = await fixture(), b = await browser();
const web = await startServer({
- port: 0, board: f.boardURL,
+ port: 0, board: f.board,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }),
projectFile: async args => { reads.push(args.path); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
diff --git a/packages/webui/tests/fixture.mjs b/packages/webui/tests/fixture.mjs
index 1a3718efd..893892e1a 100644
--- a/packages/webui/tests/fixture.mjs
+++ b/packages/webui/tests/fixture.mjs
@@ -1,12 +1,19 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
-// Run the real board scanner/server against temporary data only.
+// Run the real board scanner in-process (row 198) against temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { startServer as startBoard } from '../../control-board/src/serve.mjs';
+import { createBoard } from '../../control-board/src/board.mjs';
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
+// The Console's board: the same createBoard context the CLI builds, over a temporary data root.
+// setDown(true) makes the next read fail, which is how a test shows the page a board outage.
+export function inProcessBoard({ root, specs, seatsDir, isAlive, exec }) {
+ const board = createBoard({ specs, boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, isAlive, exec });
+ let down = false;
+ return { ...board, index: () => { if (down) throw new Error('fixture board is down'); return board.index(); }, setDown(value) { down = value; } };
+}
export async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
const seatsDir = join(root, 'seats'), specs = [];
@@ -29,12 +36,11 @@ export async function fixture() {
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
}
}
- const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
+ const board = inProcessBoard({ root, specs, seatsDir, isAlive: tmux => tmux.session !== 'agent4',
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal ' : '' }; },
});
- const boardURL = `http://127.0.0.1:${board.address().port}`;
- const web = await startServer({ port: 0, board: boardURL });
- return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
- async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
+ const web = await startServer({ port: 0, board });
+ return { root, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
+ async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
};
}
diff --git a/packages/webui/tests/history-fixture.mjs b/packages/webui/tests/history-fixture.mjs
index 2120c3e6d..e04e8c800 100644
--- a/packages/webui/tests/history-fixture.mjs
+++ b/packages/webui/tests/history-fixture.mjs
@@ -5,10 +5,9 @@
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
-import { close } from './fixture.mjs';
+import { close, inProcessBoard } from './fixture.mjs';
export const at = s => new Date(Date.now() + s * 1000).toISOString();
@@ -36,11 +35,11 @@ export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}
mkdirSync(sessionsDir, { recursive: true });
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
- const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
+ const board = inProcessBoard({ root, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], seatsDir, isAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
- const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
+ const web = await startServer({ port: 0, board });
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
- async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
+ async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
};
}
diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs
index f7ad1fd89..2198ed415 100644
--- a/packages/webui/tests/pinned-copy.test.mjs
+++ b/packages/webui/tests/pinned-copy.test.mjs
@@ -225,7 +225,7 @@ test('a copy that meets an empty target directory replaces it by rename, not by
// serve from a copy: /healthz, the config check, roles and RELEASE from the
// copy, and the queue read from --checkout or, without it, from the copy.
async function serve(t, cli, args, env) {
- const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
+ const child = spawn(process.execPath, [cli, 'serve', '--port', '0', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '', err = '';
child.stderr.on('data', (c) => { err += c; });
diff --git a/packages/webui/tests/preview.test.mjs b/packages/webui/tests/preview.test.mjs
index 87ea4e553..bc0c7a90d 100644
--- a/packages/webui/tests/preview.test.mjs
+++ b/packages/webui/tests/preview.test.mjs
@@ -104,7 +104,7 @@ test('the CLI refuses a partial or invalid preview flag set at start', () => {
test('the CLI with all three flags serves the preview mode', async t => {
// No system config, so the bus routes answer not-configured; the gate runs before them.
const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) };
- const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '';
const port = await new Promise((resolve, reject) => {
diff --git a/packages/webui/tests/proof-cli.test.mjs b/packages/webui/tests/proof-cli.test.mjs
index f727da606..67bd06a60 100644
--- a/packages/webui/tests/proof-cli.test.mjs
+++ b/packages/webui/tests/proof-cli.test.mjs
@@ -37,7 +37,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
writeFileSync(hook, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const original=fs.fstatSync;fs.fstatSync=(...args)=>{const s=original(...args);s.uid=process.getuid()+1;return s;};syncBuiltinESMExports();`);
cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
for (const c of cases) {
- const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
+ const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 2, 'invalid proof startup must refuse'); assert.match(r.stderr, /proof/i);
assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
if (c.value) assert.ok(!r.stderr.includes(c.value));
@@ -46,7 +46,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
const f = fixture(t);
for (const on of [true, false]) {
- const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => err += c);
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
@@ -66,7 +66,7 @@ test('CLI applies proof on the main listener and rejects that header on its test
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
await new Promise(r=>f.web.close(r));
- const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
diff --git a/packages/webui/tests/proof.test.mjs b/packages/webui/tests/proof.test.mjs
index 877c2618d..95508668f 100644
--- a/packages/webui/tests/proof.test.mjs
+++ b/packages/webui/tests/proof.test.mjs
@@ -1,7 +1,7 @@
// Row 178: fixture proof values only. No installed proof, proxy or unit is read.
import test from 'node:test';
import assert from 'node:assert/strict';
-import { createServer, request } from 'node:http';
+import { request } from 'node:http';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -24,9 +24,8 @@ async function setup(t) {
writeFileSync(file, VALUE + '\n', { mode: 0o600 });
const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
const calls = [], logs = [];
- const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
- await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
- const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
+ const board = { index: async () => { calls.push('board'); return { sessions: [] }; } };
+ const server = await serve.startServer({ port: 0, preview, proof, board,
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) });
t.after(() => close(server));
return { port: server.address().port, calls, logs, proof, file };
@@ -70,7 +69,7 @@ test('proxy proof header is refused on other Hosts and on the tester listener',
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
t.after(()=>rmSync(testerRoot,{recursive:true,force:true}));
- const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
+ const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: null });
t.after(() => close(server));
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs
index cdcb84a8d..4948cb74d 100644
--- a/packages/webui/tests/reads.test.mjs
+++ b/packages/webui/tests/reads.test.mjs
@@ -185,7 +185,7 @@ test('settings: release, board, loopback address, notifier binding name only', a
assert.equal(body.launch.instances.pm.provider, 'zai');
assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2);
assert.match(body.launch.revision, /^[a-f0-9]{64}$/);
- assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
+ assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
clean(r.text, s.base, repo.base);
// No notifier config: still 200, so appearance works; the reason is redacted.
const bare = seeded(t, { notify: false });
diff --git a/packages/webui/tests/relaunch.test.mjs b/packages/webui/tests/relaunch.test.mjs
index 08275514f..3bd650afc 100644
--- a/packages/webui/tests/relaunch.test.mjs
+++ b/packages/webui/tests/relaunch.test.mjs
@@ -3,18 +3,17 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
-import { close } from './fixture.mjs';
+import { close, inProcessBoard } from './fixture.mjs';
const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z';
const notice = `relaunched at ${launchAt}, no messages since`;
test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
- let board, web, b;
+ let web, b;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
@@ -23,16 +22,15 @@ test('both presentations replace old activity with relaunch notice, label retain
writeFileSync(file, history);
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' });
- board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } });
- const boardURL = `http://127.0.0.1:${board.address().port}`;
- web = await startServer({ port: 0, board: boardURL });
+ const board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true, exec: () => { throw Error('no transport expected'); } });
+ web = await startServer({ port: 0, board });
const webURL = `http://127.0.0.1:${web.address().port}`;
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt);
assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter');
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
- await b.navigate(boardURL); await wait('document.querySelector(".session-row")');
+ await b.navigate(webURL); await wait('document.querySelector(".session-row")');
const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector(".row-toggle").click()');
@@ -55,7 +53,7 @@ test('both presentations replace old activity with relaunch notice, label retain
await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false);
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false);
- await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
+ await b.navigate(webURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false);
- } finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
+ } finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
diff --git a/packages/webui/tests/return-flow.test.mjs b/packages/webui/tests/return-flow.test.mjs
index d8dec0911..562c4726a 100644
--- a/packages/webui/tests/return-flow.test.mjs
+++ b/packages/webui/tests/return-flow.test.mjs
@@ -10,11 +10,10 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
-import { close } from './fixture.mjs';
+import { close, inProcessBoard } from './fixture.mjs';
const at = s => new Date(Date.now() + s * 1000).toISOString();
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
@@ -22,16 +21,16 @@ const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', s
test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
- let board, web, b;
+ let web, b;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER'));
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) }));
- board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
+ const board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
- web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
+ web = await startServer({ port: 0, board });
b = await browser(); await b.viewport(1440, 1000);
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
@@ -72,5 +71,5 @@ test('reported return flow and relative Age: reply sent from the inspector, then
const later = await inspection();
assert.doesNotMatch(later, /Waiting for a reply|before your message/);
assert.match(later, /Last assistant text/);
- } finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
+ } finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
index 42159e7a3..3be433225 100644
--- a/packages/webui/tests/send-browser.test.mjs
+++ b/packages/webui/tests/send-browser.test.mjs
@@ -64,7 +64,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser();
- const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
+ const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`);
@@ -240,7 +240,7 @@ test('a sent receipt lists its replies as the page refreshes, oldest first and c
},
});
const f = await fixture(), b = await browser();
- const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
+ const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
diff --git a/packages/webui/tests/send.test.mjs b/packages/webui/tests/send.test.mjs
index 11cdb631b..342599f72 100644
--- a/packages/webui/tests/send.test.mjs
+++ b/packages/webui/tests/send.test.mjs
@@ -81,7 +81,7 @@ test('the CLI with --send and a configured bus sends through the human transport
// No bus host is running, so the send is a definite refusal: no-bus-host, nothing written.
const root = tmp(t, 'webui-send-cli-');
const { env } = fixture(root);
- const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '';
const port = await new Promise((resolve, reject) => {
diff --git a/packages/webui/tests/serve.test.mjs b/packages/webui/tests/serve.test.mjs
index 901a6dd52..b4c7a0f77 100644
--- a/packages/webui/tests/serve.test.mjs
+++ b/packages/webui/tests/serve.test.mjs
@@ -1,24 +1,19 @@
-import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
-import { after as laneAfter } from 'node:test';
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { createServer, get } from 'node:http';
+import { get } from 'node:http';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
-import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
+import { startServer, isLoopback } from '../src/serve.mjs';
import { fixture, close } from './fixture.mjs';
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
-test('loopback host and board origin fail closed', async () => {
+test('loopback host fails closed', async () => {
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
assert.equal(isLoopback(h), false);
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
}
- for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:pass@127.0.0.1', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
- assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
- assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
});
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
@@ -54,59 +49,43 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
} finally { await f.close(); }
});
-test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
- const requests = [];
- const upstream = createServer(async (req, res) => {
- let body = ''; for await (const c of req) body += c;
- requests.push({ url: req.url, method: req.method, body });
- if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
- if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown ","refusal":{"code":"unknown-branch","reconcile":true}}'); }
- res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal "}');
- });
- await new Promise(r => upstream.listen(0, '127.0.0.1', r));
- const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
+test('in-process board routes: writes refused without --send, reads pass the board result through, a failing read answers an error', async () => {
+ const f = await fixture();
+ const web = await startServer({ port: 0, board: f.board });
const base = `http://127.0.0.1:${web.address().port}`;
try {
- for (const path of ['/api/reply', '/api/seen']) {
- const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
- const res = await post(base, path, raw);
- assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal "}');
- assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
+ // Without --send both writes refuse before the board runs.
+ for (const path of ['/api/seen', '/api/reply']) {
+ const res = await post(base, path, { agent: 'proj/agent1', text: 'blocked', seen: true });
+ assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
}
- for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
- try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
- }
- assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
- assert.equal(requests.length, 2);
- assert.equal((await fetch(base + '/api/board')).status, 502);
- assert.equal(requests.length, 3);
- // CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
- for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
+ assert.equal(f.captures.length, 0, 'the board never ran a write');
+ assert.equal((await fetch(base + '/api/reply')).status, 405);
+ assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
+ // Reads: the board's own status and body come back as the board sent them.
+ for (const path of ['/api/conversations', '/api/conversation?id=x']) {
const res = await fetch(base + path);
- assert.equal(res.status, 404, path);
- assert.equal(await res.text(), '{"error":"fixture unknown ","refusal":{"code":"unknown-branch","reconcile":true}}');
- assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
+ const want = await f.board.conversation(new URL(path, 'http://localhost'));
+ assert.equal(res.status, want.status, path);
+ assert.deepEqual(await res.json(), want.body, path);
}
- const before = requests.length;
- assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
- assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
- assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
- assert.equal(requests.length, before, 'refused before the board');
- // Only the conversation routes carry a query upstream.
- await fetch(base + '/api/board?x=1');
- assert.equal(requests.at(-1).url, '/api/board');
- } finally { await close(web); await close(upstream); }
+ // A failing board read is an error answer, not a crash.
+ f.board.setDown(true);
+ assert.ok((await fetch(base + '/api/board')).status >= 400);
+ } finally { await close(web); await f.close(); }
});
-test('unreachable board reports URL; CLI rejects unsupported options', async () => {
- const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
- const web = await startServer({ port: 0, board: url, timeout: 100 });
+test('no board configured: the board routes answer 503; CLI rejects retired --board and other bad options', async () => {
+ const web = await startServer({ port: 0, board: null });
try {
- const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
- assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
+ const base = `http://127.0.0.1:${web.address().port}`;
+ assert.equal((await fetch(base + '/api/board')).status, 503);
+ assert.equal((await fetch(base + '/api/conversations')).status, 503);
} finally { await close(web); }
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
}
});
+
+
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
index 134140ce3..6b76ac290 100644
--- a/packages/webui/tests/service.test.mjs
+++ b/packages/webui/tests/service.test.mjs
@@ -217,9 +217,9 @@ test('render prints the unit without the preview flags', async (t) => {
test('render carries every serve flag, the preview set included', async (t) => {
const dir = tmp(t);
- const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]);
+ const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', ...PREVIEW]);
assert.equal(r.status, 0, r.stderr);
- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
});
test('a partial preview set is refused', async (t) => {
@@ -240,9 +240,9 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
if (i >= 0) { preview[i + 1] = value; return preview; }
return [flag, value];
};
- for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) {
+ for (const flag of ['--business', '--public-host', '--auth-header', '--allow-user']) {
for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) {
- const value = flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`;
+ const value = `a${bad}b`;
const r = await run(dir, ['render', ...set(flag, value)]);
assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`);
assert.equal(r.stdout, '', `${flag} ${JSON.stringify(value)}`);
@@ -252,6 +252,7 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
const r = await run(dir, ['render', '--port', port]);
assert.equal(r.status, 1, `port ${JSON.stringify(port)}`);
}
+ // --board is retired (row 198): the unit has no board flag, so a render carrying one is refused.
for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 1, args.join(' '));
@@ -386,7 +387,7 @@ test('without --send, render is the template with its placeholders filled, byte
[['--port', '7340'], '--port 7340'],
[['--business', 'mosaic'], '--port 7332 --business mosaic'],
[PREVIEW, `--port 7332 ${PREVIEW_FLAGS}`],
- [['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW], `--port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 ${PREVIEW_FLAGS}`],
+ [['--port', '7340', '--business', 'mosaic.dev_1', ...PREVIEW], `--port 7340 --business mosaic.dev_1 ${PREVIEW_FLAGS}`],
]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, r.stderr);
diff --git a/packages/webui/tests/sso-browser.test.mjs b/packages/webui/tests/sso-browser.test.mjs
index 7e621ba95..89d03e3b0 100644
--- a/packages/webui/tests/sso-browser.test.mjs
+++ b/packages/webui/tests/sso-browser.test.mjs
@@ -42,7 +42,7 @@ async function setup() {
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine: true });
- const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
+ const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
diff --git a/packages/webui/tests/tester-cli.test.mjs b/packages/webui/tests/tester-cli.test.mjs
index de41d8306..f496298ac 100644
--- a/packages/webui/tests/tester-cli.test.mjs
+++ b/packages/webui/tests/tester-cli.test.mjs
@@ -36,7 +36,7 @@ test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for
const hook = join(f.root, 'spawn-hook.mjs');
writeFileSync(log, '');
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
- const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '', error = '';
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh
index 1b0d76daf..4a734eab5 100755
--- a/scripts/stack-copy.sh
+++ b/scripts/stack-copy.sh
@@ -34,6 +34,8 @@ COPY_PATHS=(
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
packages/processing-control
packages/webui/src packages/webui/README.md packages/cli/README.md
+# Row 198: the board runs in-process inside the Console, and its replies go through tools/tmux/agent-send.sh.
+ packages/control-board/src tools/tmux
packages/queue/src packages/library/src packages/library/bin
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
index 1e4ca9681..4b08ecbc4 100755
--- a/scripts/webui-service.sh
+++ b/scripts/webui-service.sh
@@ -42,7 +42,7 @@
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
-# --business ID, --board URL, and the preview set --public-host
+# --business ID, and the preview set --public-host
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
# (row 83), taken only with the preview set: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd
@@ -93,7 +93,7 @@ instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
- FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
+ FLAGS: [--port N] [--business ID] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
--tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -113,7 +113,6 @@ TESTER_PORT=""
TESTER_SET=0
TESTER_SOCKET=0
BUSINESS=""
-BOARD=""
PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
@@ -136,7 +135,6 @@ parse_flags() {
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
- --board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
@@ -212,12 +210,6 @@ serve_flags() {
[ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
flags="$flags --business $BUSINESS"
fi
- if [ -n "$BOARD" ]; then
- # serve checks the rest (a loopback origin, no path or credentials).
- case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
- case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
- flags="$flags --board $BOARD"
- fi
local preview=0
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|