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}`); });