diff --git a/BUILD-LOG.md b/BUILD-LOG.md index b9ac03ea..f8f776fc 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -1943,3 +1943,25 @@ Suites: control-board 23/23; registry 69/69 unchanged. Live scan writes Corrections during the phase: pi `stopReason: "length"` was initially treated as "working" (now "error"); an unrunnable tmux check was initially treated as alive (now "unknown"). Registry increment 3 stays parked. Next: step 2, the web page. + +## 2026-09-12 — Control board MVP step 2 (#1503) + +Before: scanner and CLI only; status lived in JSON files under +`/board/` with no way to look at them but `--print`. + +After: `node packages/control-board/src/cli.mjs serve` starts a local, +loopback-only server (default http://127.0.0.1:7331/) that serves one +self-contained page and re-runs the scanner on every `/api/board` request. +The page lists "waiting on you" first (errors on top), then one table per +project with plain-word states, ages and last messages; rows expand to full +detail; offline rows are hidden per project by default; it refreshes every +10 seconds and can be paused. Suites: control-board 33/33 (10 new server and +page tests); registry 69/69 unchanged. Live check in Chrome with the full fleet: +18 waiting rows, 24 offline hidden, expanded detail survives a refresh. +Receipt: `docs/plans/reviews/2026-09-12_control-board-step2-review.md`. + +Corrections during the phase: `startServer` first threw synchronously for a +non-loopback host (now always rejects); expanded rows first closed on every +refresh (now persisted); the browser reported a favicon 404 (now a 204 route). +The independent reviewer had no browser, so the live check was done by the +coordinator and is recorded as such. Next: step 3, Jason uses the page daily. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 15608064..b814adf4 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -212,3 +212,5 @@ are never rewritten or removed; corrections are new entries. - 2026-09-12 — dewey (Claude Code; owner-directed takeover of dewey pi session 01a08111) — Resumed WUI Phase 2 from agents/dewey/work/wui/TASKS.md: D01 source-linked feature inventory, then D02-D05 five dashboard mockups and comparison page, folding in MOSAIC-STACK-D-001 control board as the landing view. Prototype files only under agents/dewey/work/wui; no v1 inspection, backend, credentials, commit or push. - 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 1 (#1503, D-001): plan page, ticket, `packages/control-board` scanner + CLI, 23/23 tests, live scan 42 sessions/18 waiting on Jason; independent review CHANGES REQUIRED then APPROVED after three fixes (length→error, unknown liveness state, secrets-boundary test). Receipt docs/plans/reviews/2026-09-12_control-board-step1-review.md. CURRENT next action: step 2, the page. +- 2026-09-12 — dewey (Claude Code; takeover of pi session 01a08111) — WUI Phase 2 D01-D04 done, D05 delivered: five dashboard mockups (Rail, Deck, Console, Board, Stream) plus comparison at agents/dewey/work/wui/mockups/index.html; verifier 31 checks, 0 failed, 2 not verified (forced colours, screen reader); records in VERIFY.md, README.md, TASKS.md, HISTORY.md, DECISIONS.md. Stopped at Jason's design-selection gate. No v1 inspection, backend, credentials, commit or push. +- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 2 (#1503, D-001): `packages/control-board` gains `serve` (loopback-only local server, `/api/board` re-runs the scanner) and a single-file page (waiting-on-you first, per-project groups, hide-offline, expandable rows, 10s auto-refresh with pause). Suites: control-board 33/33, registry 69/69. Static review (sonnet) found no defects; live browser check by coordinator. Receipt docs/plans/reviews/2026-09-12_control-board-step2-review.md. CURRENT next action: step 3, daily use by Jason. diff --git a/docs/plans/2026-09-12_control-board-mvp.md b/docs/plans/2026-09-12_control-board-mvp.md index 697b7542..5f0afd20 100644 --- a/docs/plans/2026-09-12_control-board-mvp.md +++ b/docs/plans/2026-09-12_control-board-mvp.md @@ -83,12 +83,20 @@ Board files are derived. They can be deleted and rebuilt at any time by running the scanner again. They are NOT run records and they are not evidence under the repository's write-once rules. -## Command +## Commands + +Scan once and print the table: ``` node packages/control-board/src/cli.mjs scan --print ``` +Start the page (step 2), then open http://127.0.0.1:7331/ in a browser: + +``` +node packages/control-board/src/cli.mjs serve +``` + Tests: ``` diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index af5b3def..d7c461e7 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -10,20 +10,20 @@ update this file to the next action). No ambiguity, no re-planning. Jason decided on 2026-09-12 (MOSAIC-STACK-D-001) that the MVP is the control board: one web page listing running agent sessions across projects, showing each one's status, and flagging which ones are waiting on him. Plan page: -`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Step 1 (status -scanner `packages/control-board`, plan page, ticket) is done in this checkout; -running `node packages/control-board/src/cli.mjs scan --print` writes one status -file per agent under `/board/`. The registry line (increment 3, -headless identity-env leak) stays parked; #1500 is closed. +`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Steps 1 and 2 +are done in this checkout: `packages/control-board` scans every Pi agent +(repo `.pi/state/*` and `~/.mosaic/fleet`) and serves the page. -Next action: step 2 of #1503 — the web page. One local page that reads -`/board/index.json`, groups sessions by project, shows the plain-word -state per agent, puts a "waiting on you" section at the top, and refreshes -itself (re-running the scanner on each refresh is acceptable). Reuse Dewey's -control-board mockups under `agents/dewey/work/wui/` for layout where they fit. -Acceptance: Jason opens the page in a browser and sees the same rows the -`--print` output shows, with no manual steps beyond starting the page. No auth, -no hooks/plugins, no comms, no launcher changes, no new root files. +Start it with `node packages/control-board/src/cli.mjs serve` and open +http://127.0.0.1:7331/ (loopback only, no auth, no daemon; Ctrl-C stops it). + +Next action: step 3 of #1503 — daily use. Jason opens the page while he works +and reports what is wrong, missing or confusing; each report becomes a small +fix on this package with a test, committed and pushed to `refactor`. Close +#1503 when Jason says the page answers "who is waiting on me" without him +opening a terminal. Out of scope until he asks: auth, hooks/plugins, comms, +memory, launcher changes, non-Pi harnesses, new root files. The registry line +(increment 3, headless identity-env leak) stays parked; #1500 is closed. ## Completed checkpoint: #1500 increment 2 (historical) diff --git a/docs/plans/reviews/2026-09-12_control-board-step2-review.md b/docs/plans/reviews/2026-09-12_control-board-step2-review.md new file mode 100644 index 00000000..4dd4ed58 --- /dev/null +++ b/docs/plans/reviews/2026-09-12_control-board-step2-review.md @@ -0,0 +1,60 @@ +# Control board MVP step 2: the page, implementation and review receipt + +Date: 2026-09-12. Recorded by jarvis (Claude Code, coordinator). Tracking: #1503. +Plan: `docs/plans/2026-09-12_control-board-mvp.md`. Step 1 receipt: +`2026-09-12_control-board-step1-review.md`. + +## What was built + +- `packages/control-board/src/serve.mjs`: a small local web server with no + dependencies and no auth. Routes: `/` (the page), `/api/board` (re-runs the + scanner and returns `index.json`), `/healthz`, `/favicon.ico` (empty). It + refuses to bind to anything but a loopback address. +- `packages/control-board/src/page.html`: one self-contained page. "Waiting on + you" at the top (error rows first), then one table per project with a + plain-word state badge, age, and last message. Rows expand to a detail panel; + open panels and the per-project "hide offline" choice survive the 10-second + auto-refresh. Pause/Resume and a manual Refresh button. All API text is + escaped before it reaches the page. +- `src/cli.mjs`: new `serve` command (`--port`, `--host`). + +## Tests + +`node --test packages/control-board/tests/` — 33 tests, 33 pass, 0 fail +(23 scanner tests from step 1, 10 new: loopback rules, host refusal without a +socket, every route, per-request rescan, 500 path, CLI refusal/exit codes, a +live spawned `serve`, and a static check that the page's esc() helper escapes +every HTML-significant character and that no API value is joined into markup +without it). + +## Live check in a real browser + +Server started with the full fleet and real tmux liveness on port 7332; page +opened in Chrome through DevTools. Seen: 18 rows under "Waiting on you" with +the three fleet 429-error rows first; two project groups (mosaic-stack 4, +fleet 38) with "24 offline hidden"; unticking "Hide offline" showed the +offline rows; Pause changed the header to "auto-refresh paused"; a row toggle +opened its detail panel and stayed open across the next refresh. Console: +only a favicon 404 (a `/favicon.ico` route was added after; no page errors). +Screenshot: `/tmp/control-board-review.png` (not committed). + +## Independent review + +Reviewer: in-session code-review agent (Claude Sonnet), static read-only. +Findings: none at reportable confidence for XSS, host-check bypass, request +handling, fail-closed behaviour, or doc drift. It could not run the browser +check itself (no shell or browser tools in its session), so it withheld a +final approval on that ground; the browser check above was done by the +coordinator instead. Two minor notes acted on: a stale test comment, and +adding a test for the page's escaping. A separate test-writing helper flagged +that `startServer` threw synchronously for a bad host while other failures +rejected; it is now `async` so every failure is a rejection. + +Corrections during this step: open detail panels originally closed on every +refresh (fixed, keyed by section/project/agent); the escaping guard test +initially flagged my own key-building line and a comment (reworded). + +## Boundaries kept + +No launcher changes, no new root files, no secrets, no auth (loopback only), +no daemon, no changes to `packages/mosaic`. Next: step 3, daily use by Jason. diff --git a/packages/control-board/README.md b/packages/control-board/README.md index 3604c28a..6437d457 100644 --- a/packages/control-board/README.md +++ b/packages/control-board/README.md @@ -18,12 +18,19 @@ rewritable; they are not run records and are not evidence. | idle | The agent is live but has not had a conversation yet. | | unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. | -## Command +## Commands ``` -node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] +node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] +node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1] ``` +`scan` runs once and writes the status files. `serve` starts a small local +web server: open `http://127.0.0.1:7331/` in a browser. The page fetches +`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is +never staler than that timer. There is no login, so the server refuses to +bind to anything but a loopback address. + - `--config PATH` — path to the system config file. Defaults to `~/.config/mosaic-dev/config.json`. This file must exist and name an absolute `dataRoot`, or the scanner refuses to run. @@ -35,11 +42,17 @@ node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liven - `--liveness tmux|assume-alive` — how to decide if an agent is alive. `tmux` (default) checks the real tmux session. `assume-alive` treats every agent as alive, useful for tests or environments without tmux. -- `--print` — also print a one-line-per-agent table to stdout. +- `--print` — (`scan`) also print a one-line-per-agent table to stdout. +- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port. +- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`. + Any non-loopback address is refused. + +Routes served: `/` (the page), `/api/board` (rescan, returns `index.json`), +`/healthz`. ## Exit codes -- `0` — scan completed and status files were written. +- `0` — scan completed and status files were written, or the server stopped cleanly. - `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments. The message on stderr says why. diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs index 4c91e914..30ed4281 100755 --- a/packages/control-board/src/cli.mjs +++ b/packages/control-board/src/cli.mjs @@ -1,12 +1,17 @@ #!/usr/bin/env node -// Usage: node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] +// Usage: +// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] +// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1] // Exit 0 on success, 2 on a config refusal or bad usage. import { join } from "node:path"; import { homedir } from "node:os"; import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs"; +import { startServer } from "./serve.mjs"; + +const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]"; function parseArgs(argv) { - const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false }; + const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" }; for (let i = 1; i < argv.length; i++) { const a = argv[i]; const next = () => { @@ -18,19 +23,32 @@ function parseArgs(argv) { else if (a === "--fleet") opts.fleet = next(); else if (a === "--liveness") opts.liveness = next(); else if (a === "--print") opts.print = true; + else if (a === "--port") { + opts.port = Number(next()); + if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535"); + } else if (a === "--host") opts.host = next(); else throw new ConfigError(`unknown argument: ${a}`); } - if (opts.command !== "scan") throw new ConfigError("usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]"); + if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE); if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`); return opts; } -function main() { +async function main() { const opts = parseArgs(process.argv.slice(2)); const { dataRoot } = loadConfig(opts.config); const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))]; const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true; const boardDir = join(dataRoot, "board"); + if (opts.command === "serve") { + const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive }); + const addr = server.address(); + process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`); + const stop = () => server.close(() => process.exit(0)); + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + return; + } const index = scan(specs, { boardDir, isAlive }); if (opts.print) { for (const s of index.sessions) { @@ -42,12 +60,10 @@ function main() { process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`); } -try { - main(); -} catch (err) { +main().catch((err) => { if (err instanceof ConfigError) { process.stderr.write(`refused: ${err.message}\n`); process.exit(2); } throw err; -} +}); diff --git a/packages/control-board/src/page.html b/packages/control-board/src/page.html new file mode 100644 index 00000000..6cf3ac08 --- /dev/null +++ b/packages/control-board/src/page.html @@ -0,0 +1,354 @@ + + + + + +Control board + + + +
+

Control board

+
+

Updated never · next refresh in 10s

+ + +
+
+ +
+
+

Waiting on you

+
+
+
+

By project

+
+
+
+
+ + + diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs new file mode 100644 index 00000000..6abe743a --- /dev/null +++ b/packages/control-board/src/serve.mjs @@ -0,0 +1,75 @@ +// Control board step 2: a tiny local web server. No dependencies, no auth. +// It only ever binds to a loopback address (fail closed otherwise). +// +// GET / the page (src/page.html) +// GET /api/board re-runs the scanner and returns index.json as JSON +// GET /healthz {"ok":true} +// +// Every /api/board request rescans, so the page is never staler than its +// refresh timer. The scan rewrites the derived board files as a side effect. + +import { createServer as createHttpServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isIP } from "node:net"; +import { scan, ConfigError } from "./scan.mjs"; + +const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); + +export function isLoopbackHost(host) { + if (LOOPBACK.has(host)) return true; + return isIP(host) === 4 && host.startsWith("127."); +} + +export function loadPage(path = join(import.meta.dirname, "page.html")) { + return readFileSync(path, "utf8"); +} + +// specs: agent specs to scan on each request. boardDir: where scan writes. +export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) { + return createHttpServer((req, res) => { + const url = new URL(req.url, "http://localhost"); + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405, { "content-type": "text/plain" }); + return res.end("method not allowed\n"); + } + if (url.pathname === "/" || url.pathname === "/index.html") { + res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + return res.end(page); + } + if (url.pathname === "/api/board") { + let index; + try { + index = scan(specs, { boardDir, isAlive, now }); + } catch (err) { + res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" }); + return res.end(JSON.stringify({ error: err.message }) + "\n"); + } + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + return res.end(JSON.stringify(index) + "\n"); + } + if (url.pathname === "/favicon.ico") { + res.writeHead(204); + return res.end(); + } + if (url.pathname === "/healthz") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end('{"ok":true}\n'); + } + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found\n"); + }); +} + +// Resolves to the listening server. Refuses any non-loopback host. +export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) { + if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`); + const server = createServer(rest); + return new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolvePromise(server); + }); + }); +} diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs new file mode 100644 index 00000000..2d9eb48c --- /dev/null +++ b/packages/control-board/tests/serve.test.mjs @@ -0,0 +1,338 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + readFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync, spawn } from "node:child_process"; +import { createServer as createNetServer } from "node:net"; +import { ConfigError } from "../src/scan.mjs"; +import { isLoopbackHost, startServer } from "../src/serve.mjs"; + +const pkgRoot = resolve(import.meta.dirname, ".."); +const cli = join(pkgRoot, "src", "cli.mjs"); + +// --------------------------------------------------------------------------- +// Fixture helpers, copied from scan.test.mjs (kept local so that file stays +// untouched; do not import unexported helpers across test files). +// --------------------------------------------------------------------------- + +// Track every tmpdir we create so a stray failure never leaves fixtures behind. +const roots = []; +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), "control-board-serve-test-")); + roots.push(root); + return root; +} +after(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }); +}); + +function writeFile(path, content) { + mkdirSync(resolve(path, ".."), { recursive: true }); + writeFileSync(path, content); +} + +function sessionLine({ id, timestamp, cwd }) { + return JSON.stringify({ type: "session", id, timestamp, cwd }); +} +function messageLine({ timestamp, role, stopReason, texts }) { + const message = { role }; + if (stopReason !== undefined) message.stopReason = stopReason; + if (texts) message.content = texts.map((text) => ({ type: "text", text })); + return JSON.stringify({ type: "message", timestamp, message }); +} + +function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) { + const path = join(dir, name); + writeFile(path, lines.join("\n") + (trailingNewline ? "\n" : "")); + return path; +} + +// --------------------------------------------------------------------------- +// serve.mjs-specific helpers +// --------------------------------------------------------------------------- + +function closeServer(server) { + return new Promise((resolvePromise) => server.close(resolvePromise)); +} + +// Grab an ephemeral free port, then hand it back immediately so a caller can +// try to bind it themselves (used to prove startServer never opened a socket). +function getFreePort() { + return new Promise((resolvePromise, reject) => { + const probe = createNetServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const port = probe.address().port; + probe.close(() => resolvePromise(port)); + }); + }); +} + +function runCli(args) { + return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 }); +} + +// --------------------------------------------------------------------------- +// 1. isLoopbackHost +// --------------------------------------------------------------------------- + +test("isLoopbackHost: recognizes loopback hosts", () => { + assert.equal(isLoopbackHost("127.0.0.1"), true); + assert.equal(isLoopbackHost("::1"), true); + assert.equal(isLoopbackHost("localhost"), true); + assert.equal(isLoopbackHost("127.5.5.5"), true); +}); + +test("isLoopbackHost: rejects non-loopback hosts", () => { + assert.equal(isLoopbackHost("0.0.0.0"), false); + assert.equal(isLoopbackHost("192.168.1.2"), false); + assert.equal(isLoopbackHost("::"), false); + assert.equal(isLoopbackHost(""), false); + assert.equal(isLoopbackHost("evil.example"), false); +}); + +// --------------------------------------------------------------------------- +// 2. startServer: fail-closed on a non-loopback host +// --------------------------------------------------------------------------- + +test("startServer: refuses a non-loopback host with ConfigError, never opens a socket", async () => { + const root = makeRoot(); + const port = await getFreePort(); + let caught = null; + try { + // startServer is async, so the host refusal surfaces as a rejection + // before any listen() call happens. + await startServer({ + host: "192.168.1.2", + port, + specs: [], + boardDir: join(root, "board"), + isAlive: () => true, + }); + } catch (err) { + caught = err; + } + assert.ok(caught instanceof ConfigError, "expected a ConfigError"); + + // The port must still be free: startServer must never have called listen(). + await new Promise((resolvePromise, reject) => { + const probe = createNetServer(); + probe.once("error", reject); + probe.listen(port, "127.0.0.1", () => probe.close(resolvePromise)); + }); +}); + +// --------------------------------------------------------------------------- +// 3. createServer routes, via startServer on port 0 +// --------------------------------------------------------------------------- + +test("startServer: serves page, healthz, and a rescanning /api/board", async () => { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + writeSessionFile(sessionsDir, "s.jsonl", [ + sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + ]); + const boardDir = join(root, "board"); + const page = "injected test page"; + const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }]; + + const server = await startServer({ + host: "127.0.0.1", + port: 0, + specs, + boardDir, + isAlive: () => true, + page, + }); + const base = `http://127.0.0.1:${server.address().port}`; + + try { + for (const path of ["/", "/index.html"]) { + const res = await fetch(`${base}${path}`); + assert.equal(res.status, 200, path); + assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path); + assert.equal(res.headers.get("cache-control"), "no-store", path); + assert.equal(await res.text(), page, path); + } + + const health = await fetch(`${base}/healthz`); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { ok: true }); + + const first = await fetch(`${base}/api/board`); + assert.equal(first.status, 200); + assert.equal(first.headers.get("content-type"), "application/json"); + assert.equal(first.headers.get("cache-control"), "no-store"); + const firstBody = await first.json(); + assert.equal(firstBody.sessions[0].state, "waiting"); + assert.ok(firstBody.waitingOnYou.includes("proj/agent1")); + + assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan"); + assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan"); + + // Rewrite the fixture to a "user" last message (working state) and hit + // /api/board again: a fresh scan must reflect the new state, proving + // each request rescans instead of caching. + writeSessionFile(sessionsDir, "s.jsonl", [ + sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), + messageLine({ timestamp: "2026-09-01T00:05:00Z", role: "user", texts: ["go again"] }), + ]); + const second = await fetch(`${base}/api/board`); + assert.equal(second.status, 200); + const secondBody = await second.json(); + assert.equal(secondBody.sessions[0].state, "working"); + + const missing = await fetch(`${base}/nope`); + assert.equal(missing.status, 404); + + const posted = await fetch(`${base}/api/board`, { method: "POST" }); + assert.equal(posted.status, 405); + } finally { + await closeServer(server); + } +}); + +// --------------------------------------------------------------------------- +// 4. /api/board: scan failure surfaces as a 500 with an error field +// --------------------------------------------------------------------------- + +test("startServer: /api/board returns 500 JSON with an error field when scan throws", async () => { + const server = await startServer({ + host: "127.0.0.1", + port: 0, + specs: [], + // Relative boardDir: scan() throws ConfigError("boardDir must be an absolute path"). + boardDir: "relative/board", + isAlive: () => true, + page: "", + }); + const base = `http://127.0.0.1:${server.address().port}`; + + try { + const res = await fetch(`${base}/api/board`); + assert.equal(res.status, 500); + assert.equal(res.headers.get("content-type"), "application/json"); + const body = await res.json(); + assert.equal(typeof body.error, "string"); + assert.ok(body.error.length > 0); + } finally { + await closeServer(server); + } +}); + +// --------------------------------------------------------------------------- +// 5. CLI +// --------------------------------------------------------------------------- + +test("CLI: serve refuses a non-loopback host with exit 2 and a refused: message", () => { + const root = makeRoot(); + const dataRoot = join(root, "data"); + const configPath = join(root, "config.json"); + writeFile(configPath, JSON.stringify({ dataRoot })); + const repoRoot = join(root, "repo"); + mkdirSync(repoRoot, { recursive: true }); + + const r = runCli(["serve", "--host", "0.0.0.0", "--config", configPath, "--repo", repoRoot, "--fleet", "none"]); + assert.equal(r.status, 2); + assert.match(r.stderr, /^refused:/); +}); + +test("CLI: serve rejects a non-numeric --port with exit 2", () => { + const r = runCli(["serve", "--port", "abc"]); + assert.equal(r.status, 2); + assert.match(r.stderr, /^refused:/); +}); + +test("CLI: scan still works after the async cli refactor", () => { + const root = makeRoot(); + const dataRoot = join(root, "data"); + const configPath = join(root, "config.json"); + writeFile(configPath, JSON.stringify({ dataRoot })); + const repoRoot = join(root, "repo"); + mkdirSync(repoRoot, { recursive: true }); + + const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive"]); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^board: /m); +}); + +test("CLI: live serve prints its URL and answers /healthz", async () => { + const root = makeRoot(); + const dataRoot = join(root, "data"); + const configPath = join(root, "config.json"); + writeFile(configPath, JSON.stringify({ dataRoot })); + const repoRoot = join(root, "repo"); + mkdirSync(repoRoot, { recursive: true }); + + const child = spawn( + process.execPath, + [cli, "serve", "--port", "0", "--liveness", "assume-alive", "--config", configPath, "--repo", repoRoot, "--fleet", "none"], + { stdio: ["ignore", "pipe", "pipe"] } + ); + + let stdoutBuf = ""; + let stderrBuf = ""; + child.stderr.on("data", (chunk) => { + stderrBuf += chunk.toString(); + }); + + let url; + try { + url = await new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + reject(new Error(`timed out waiting for the server line; stdout=${JSON.stringify(stdoutBuf)} stderr=${JSON.stringify(stderrBuf)}`)); + }, 15000); + child.stdout.on("data", (chunk) => { + stdoutBuf += chunk.toString(); + const match = stdoutBuf.match(/^control board: (http:\/\/127\.0\.0\.1:\d+)\//m); + if (match) { + clearTimeout(timer); + resolvePromise(match[1]); + } + }); + child.on("exit", (code) => { + clearTimeout(timer); + reject(new Error(`child exited early with code ${code}; stderr=${stderrBuf}`)); + }); + }); + + const res = await fetch(`${url}/healthz`); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + } finally { + child.kill("SIGTERM"); + await new Promise((resolvePromise) => { + if (child.exitCode !== null || child.signalCode !== null) return resolvePromise(); + child.on("exit", resolvePromise); + }); + } +}); + +// The page's only XSS defence is its inline esc() helper. Pull that function +// out of page.html by name and check it inerts every HTML-significant char. +test("page.html: esc() escapes every HTML-significant character", () => { + const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8"); + const m = html.match(/function esc\(v\) \{[\s\S]*?\n \}/); + assert.ok(m, "esc() must exist in page.html"); + const esc = new Function(`${m[0]}; return esc;`)(); + assert.equal(esc('&\''), "<script>alert("x")</script>&'"); + assert.equal(esc(null), ""); + assert.equal(esc(undefined), ""); + assert.equal(esc(42), "42"); + // The page builds HTML by string concatenation. Any API value joined + // straight into markup ("+ rec.x" / "+ project" / "+ data.x") would bypass + // esc(); require zero such joins so a regression is caught here. + const rawJoins = [...html.matchAll(/\+\s*(rec\.[\w.]+|project|data\.[\w.]+)\b(?!\s*\|\|)/g)].map((x) => x[0]); + assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`); + const escCalls = (html.match(/\besc\(/g) || []).length; + assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`); +});