History opens a seat's conversation from the Waiting card, table row and inspector. It pages the whole branch through the CHAT-02 board routes, renders untrusted text inert, polls with the follow cursor, and marks every switch (branch, newer, reconcile, gone). The WebUI proxy passes only the two conversation routes' queries upstream. Dewey authored it. Filbert asked for changes on r1 (24b046af) and approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer', not 'reconcile', a deviation from brief 2.3 item 6 that Filbert accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1184 lines
60 KiB
JavaScript
1184 lines
60 KiB
JavaScript
import { test, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
rmSync,
|
|
readFileSync,
|
|
existsSync,
|
|
chmodSync,
|
|
statSync,
|
|
symlinkSync,
|
|
lstatSync,
|
|
readdirSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve, basename } from "node:path";
|
|
import { spawnSync, spawn } from "node:child_process";
|
|
import { createServer as createNetServer } from "node:net";
|
|
import { request as httpRequest } from "node:http";
|
|
import { ConfigError, markSeen } from "../src/scan.mjs";
|
|
import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER, REFUSAL_STATUS } from "../src/serve.mjs";
|
|
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
|
|
import { UNSUPPORTED_HARNESS } from "../../conversation/src/reader.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 = "<html><body>injected test page</body></html>";
|
|
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, "idle");
|
|
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);
|
|
}
|
|
});
|
|
|
|
test("startServer: a seatsDir registration overrides the row and index.registered reflects it", 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: "user", texts: ["derived task"] }),
|
|
]);
|
|
const boardDir = join(root, "board");
|
|
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);
|
|
|
|
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
|
const server = await startServer({
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
specs,
|
|
boardDir,
|
|
seatsDir,
|
|
isAlive: () => ({ alive: true, workspace: null }),
|
|
page: "<html></html>",
|
|
});
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const res = await fetch(`${base}/api/board`);
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.sessions[0].taskSource, "registration");
|
|
assert.equal(body.sessions[0].task, "registered task");
|
|
assert.deepEqual(body.registered, ["proj/agent1"]);
|
|
assert.deepEqual(body.registrationErrors, []);
|
|
} 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: "<html></html>",
|
|
});
|
|
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>&\''), "<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}`);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. POST /api/seen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it", 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: ["Input needed: Confirm the fixture."] }),
|
|
]);
|
|
const boardDir = join(root, "board");
|
|
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
|
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir, isAlive: () => true, page: "<html></html>" });
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const postRes = await fetch(`${base}/api/seen`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }),
|
|
});
|
|
assert.equal(postRes.status, 200);
|
|
const postBody = await postRes.json();
|
|
assert.ok(postBody.seen.includes("proj/agent1"));
|
|
assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
|
|
|
|
const boardRes = await fetch(`${base}/api/board`);
|
|
assert.equal(boardRes.status, 200);
|
|
const boardBody = await boardRes.json();
|
|
assert.ok(boardBody.seen.includes("proj/agent1"));
|
|
assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
|
|
|
|
const clearRes = await fetch(`${base}/api/seen`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z", seen: false }),
|
|
});
|
|
assert.equal(clearRes.status, 200);
|
|
const clearBody = await clearRes.json();
|
|
assert.ok(!clearBody.seen.includes("proj/agent1"));
|
|
assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
test("POST /api/seen without a JSON content-type returns 400 and does not write a mark", 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 specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
|
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir, isAlive: () => true, page: "<html></html>" });
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const res = await fetch(`${base}/api/seen`, {
|
|
method: "POST",
|
|
headers: { "content-type": "text/plain" },
|
|
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }),
|
|
});
|
|
assert.equal(res.status, 400);
|
|
assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
test("POST /api/seen with invalid JSON returns 400", async () => {
|
|
const root = makeRoot();
|
|
const boardDir = join(root, "board");
|
|
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const res = await fetch(`${base}/api/seen`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: "{ not json",
|
|
});
|
|
assert.equal(res.status, 400);
|
|
assert.ok(!existsSync(join(boardDir, "seen.json")));
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
test("POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark", async () => {
|
|
const root = makeRoot();
|
|
const boardDir = join(root, "board");
|
|
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
const big = JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "x".repeat(5000) });
|
|
|
|
try {
|
|
let status = null;
|
|
try {
|
|
const res = await fetch(`${base}/api/seen`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: big,
|
|
});
|
|
status = res.status;
|
|
} catch {
|
|
status = null; // a destroyed connection is an acceptable outcome too
|
|
}
|
|
if (status !== null) assert.equal(status, 400);
|
|
assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
test("POST /api/seen with a missing agent returns 400", async () => {
|
|
const root = makeRoot();
|
|
const boardDir = join(root, "board");
|
|
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const res = await fetch(`${base}/api/seen`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ project: "proj", lastActivity: "2026-09-01T00:00:01Z" }),
|
|
});
|
|
assert.equal(res.status, 400);
|
|
assert.ok(!existsSync(join(boardDir, "seen.json")));
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
test("POST /api/board returns 405; PUT /api/seen returns 405", async () => {
|
|
const root = makeRoot();
|
|
const boardDir = join(root, "board");
|
|
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const board = await fetch(`${base}/api/board`, { method: "POST" });
|
|
assert.equal(board.status, 405);
|
|
const put = await fetch(`${base}/api/seen`, { method: "PUT" });
|
|
assert.equal(put.status, 405);
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. CLI: seen rows in --print
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'", () => {
|
|
const root = makeRoot();
|
|
const dataRoot = join(root, "data");
|
|
const configPath = join(root, "config.json");
|
|
writeFile(configPath, JSON.stringify({ dataRoot }));
|
|
const repoRoot = join(root, "repo");
|
|
const sessionsDir = join(repoRoot, ".pi", "state", "agent1", "sessions");
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
const lastActivity = "2026-09-01T00:00:01Z";
|
|
writeFile(
|
|
join(sessionsDir, "s.jsonl"),
|
|
[
|
|
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
|
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
|
].join("\n") + "\n"
|
|
);
|
|
const boardDir = join(dataRoot, "board");
|
|
const project = basename(resolve(repoRoot));
|
|
markSeen(boardDir, { project, agent: "agent1", lastActivity, seen: true });
|
|
|
|
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive", "--print"]);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const row = r.stdout.split("\n").find((l) => l.includes("agent1"));
|
|
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
|
|
assert.equal(row[0], "s");
|
|
assert.match(r.stdout, /1 seen, 0 registered, 0 stale\)\s*$/m);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. page.html: the seen-toggle control escapes its values and posts JSON
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type", () => {
|
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
|
const m = html.match(/function seenControl\(rec\) \{[\s\S]*?\n \}/);
|
|
assert.ok(m, "seenControl() must exist in page.html");
|
|
const body = m[0];
|
|
assert.match(body, /esc\(rec\.project\)/);
|
|
assert.match(body, /esc\(rec\.agent\)/);
|
|
assert.match(body, /esc\(rec\.lastActivity\)/);
|
|
assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json");
|
|
});
|
|
|
|
test("page.html: has a collapsed Seen section that lists seen rows with the shared row builder", () => {
|
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
|
assert.match(html, /<details id="seenDetails">(?![^>]*\sopen)/, "the Seen section must start collapsed");
|
|
assert.match(html, /<h2 id="seen-h">Seen<\/h2>/);
|
|
const m = html.match(/function renderSeen\(data\) \{[\s\S]*?\n \}/);
|
|
assert.ok(m, "renderSeen() must exist in page.html");
|
|
assert.match(m[0], /filter\(function \(r\) \{ return r\.seen; \}\)/, "the Seen section lists exactly the rows the API marks seen");
|
|
assert.match(m[0], /buildRowPair\(r, true\)/, "seen rows reuse the escaped row builder, project column included");
|
|
assert.match(html, /renderWaiting\(lastData\);\n\s*renderSeen\(lastData\);/, "renderAll must render the Seen section on every refresh");
|
|
});
|
|
|
|
test("page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note", () => {
|
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
|
const m = html.match(/function renderProjects\(data\) \{[\s\S]*?\n \}/);
|
|
assert.ok(m, "renderProjects() must exist in page.html");
|
|
const body = m[0];
|
|
assert.match(body, /if \(!\(project in hideSeenState\)\) hideSeenState\[project\] = true/, "Hide seen defaults to on and the choice survives re-render");
|
|
assert.match(body, /class="hide-seen-toggle" data-project="' \+ esc\(project\)/, "the checkbox carries the escaped project name");
|
|
assert.match(body, /hideSeen && r\.seen/, "seen rows are filtered when the box is ticked");
|
|
assert.match(body, /" seen hidden"/, "the note reports how many seen rows are hidden");
|
|
assert.match(html, /closest\("\.hide-offline-toggle, \.hide-seen-toggle"\)/, "one change handler serves both checkboxes");
|
|
});
|
|
|
|
test("page.html: a project header reads \"N of N\" only while a checkbox hides rows", () => {
|
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
|
const m = html.match(/function renderProjects\(data\) \{[\s\S]*?\n \}/);
|
|
assert.ok(m, "renderProjects() must exist in page.html");
|
|
const body = m[0];
|
|
assert.match(
|
|
body,
|
|
/var headCount = visible\.length < group\.length \? visible\.length \+ " of " \+ group\.length : String\(group\.length\)/,
|
|
"shown-of-total only when something is hidden; the plain total otherwise",
|
|
);
|
|
assert.match(body, /<span>\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length");
|
|
assert.doesNotMatch(body, /<span>\(' \+ group\.length/, "the raw group length must no longer be rendered in the header");
|
|
});
|
|
|
|
test("page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail", () => {
|
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
|
const m = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
|
|
assert.ok(m, "buildRowPair() must exist in page.html");
|
|
const body = m[0];
|
|
assert.match(body, /rec\.task\s*\?[\s\S]*esc\(rec\.task\)[\s\S]*:\s*'<span class="unknown">unknown<\/span>'/, "task is escaped, or reads unknown when null");
|
|
assert.match(body, /rec\.activeProject\s*\?[\s\S]*esc\(rec\.activeProject\)[\s\S]*unknown/, "active project is escaped, or reads unknown when null");
|
|
assert.match(body, /<dt>Task<\/dt>/, "detail lists the task");
|
|
assert.match(body, /<dt>Active project<\/dt>/, "detail lists the active project");
|
|
assert.match(body, /<dt>Workspace<\/dt><dd>" \+ \(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(/<th scope=\\"col\\">Task<\/th><th scope=\\"col\\">Active project<\/th>/g) || [];
|
|
assert.equal(heads.length, 3, "all three tables carry the two new headers");
|
|
assert.match(html, /<td colspan="6" class="empty">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],
|
|
/<dt>Registered<\/dt><dd>" \+ 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], /<dt>Model<\/dt><dd>" \+ 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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A fake tools/tmux/agent-send.sh: records argv and the env it saw, exits
|
|
// with FAKE_SEND_EXIT (default 0) and writes FAKE_SEND_STDERR to stderr.
|
|
function writeFakeAgentSend(root) {
|
|
const path = join(root, "agent-send.sh");
|
|
writeFile(path, `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
fs.writeFileSync(process.env.FAKE_SEND_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), tmuxSocketEnv: process.env.MOSAIC_TMUX_SOCKET ?? null }));
|
|
process.stdout.write("fake sent\\n");
|
|
if (process.env.FAKE_SEND_STDERR) process.stderr.write(process.env.FAKE_SEND_STDERR);
|
|
process.exit(Number(process.env.FAKE_SEND_EXIT || 0));
|
|
`);
|
|
chmodSync(path, 0o755);
|
|
return path;
|
|
}
|
|
|
|
function replyFixture({ tmux = { socket: null, session: "agent1" }, pid = process.pid, registered = true } = {}) {
|
|
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 seatsDir = join(root, "seats");
|
|
if (registered) {
|
|
writeRegistration(seatsDir, makeRegistration({
|
|
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
|
|
task: "t", tmux, pid,
|
|
}));
|
|
}
|
|
const capture = join(root, "capture.json");
|
|
process.env.FAKE_SEND_CAPTURE = capture;
|
|
return { root, sessionsDir, seatsDir, boardDir: join(root, "board"), agentSend: writeFakeAgentSend(root), capture,
|
|
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 <host>: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: "<html></html>", 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 <socket>", 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: "<html></html>", 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: "<html></html>", 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: "<html></html>", 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: "<html></html>", 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 '<span class="unknown">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], /<dt>Reply<\/dt><dd>" \+ 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: "<html></html>" });
|
|
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], /<dt>Task set by<\/dt><dd>" \+ 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: "<html></html>", 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: "<html></html>", 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: "<html></html>", 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");
|
|
const src = readdirSync(dir).filter((f) => f.endsWith(".mjs")).map((f) => readFileSync(join(dir, f), "utf8")).join("\n");
|
|
const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1]));
|
|
codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason
|
|
assert.ok(codes.size >= 15, [...codes].join(" "));
|
|
assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
|
assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
|
// The statuses themselves, so a changed value fails here even where no route test reaches it.
|
|
assert.deepEqual(REFUSAL_STATUS, {
|
|
"unknown-conversation": 404, "unknown-branch": 404, unavailable: 404,
|
|
"cursor-unknown": 409, "cursor-expired": 409, "cursor-foreign": 409, "source-replaced": 409, "incomplete-header": 409,
|
|
"unsafe-path": 403, "foreign-project": 403, unreadable: 403, "unknown-actor": 403,
|
|
"unsupported-harness": 422, "not-a-pi-session": 422, "too-large": 422, "unsupported-purpose": 422,
|
|
});
|
|
});
|
|
|
|
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: "<html></html>" });
|
|
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() };
|
|
};
|
|
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");
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|