// 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 // POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen // (or clears the mark with seen:false); rescans, returns index // POST /api/reply {agent: "/", text} delivers text to the // seat's tmux pane through tools/tmux/agent-send.sh (#1505); // answers {delivered, exitCode, stdout, stderr, ...} // GET /healthz {"ok":true} // GET /api/conversations // read-only Pi conversation catalogue (#1507, CHAT-02) // GET /api/conversation?id=[&branch=][&cursor=] // one CHAT-01 history page: the first page of a branch (the // default one without branch), or the next page (or a // follow) for a cursor. A cursor call repeats the page's // branch; without it the answer is 400. Refusals carry // {error, refusal: {code, reconcile}}. // // POST requires Content-Type: application/json. A plain form post from another // site in the browser cannot set that header without a CORS preflight, and this // server answers no preflight, so a stray page cannot flip marks. // // Every route first checks Host and Origin (#1507). Binding to loopback does // not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches // this server as its own origin, with its own name as Host, and could read // /api/board or post /api/reply into a live pane. A Host that is not a loopback // name on this server's port, or any Origin other than this server's own, gets // 403 before anything else runs. Same check as packages/webui/src/serve.mjs. // No CORS headers are ever sent. // // 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. // // The conversation routes read only: packages/conversation lists the repository // specs' Pi session roots (never fleet or connector ones), opens files // O_NOFOLLOW and writes nothing. Registrations are hints there too. The // conversation id is opaque and no path comes from the request. Cursors live // in this server's memory, bound to the one actor this unauthenticated // loopback route has, local-operator. import { createServer as createHttpServer } from "node:http"; import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { isIP } from "node:net"; import { hostname } from "node:os"; import { spawnSync } from "node:child_process"; import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs"; import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; const MAX_BODY = 4096; // Reply-from-board (#1505). The board owns no transport: a reply is handed to // the repository's own inter-agent channel, tools/tmux/agent-send.sh, which // prepends the "[ -> :]" preamble and pastes into the // seat's pane. The tool's exit code is the receipt; the board never retries, // queues or broadcasts, and never calls tmux send-keys itself. export const DEFAULT_AGENT_SEND = resolve(import.meta.dirname, "..", "..", "..", "tools", "tmux", "agent-send.sh"); export const REPLY_LIMIT = 2000; export const REPLY_SENDER = "control-board"; // Appended to every message on its own line. The board is a sender without // a pane: it reads the seat's transcript, so a seat must answer in its own // session as usual and never agent-send back to "control-board" (that // target does not exist and the tool refuses it). Jason's refinement after // the first real exchange, 2026-09-12. export const REPLY_TRAILER = "(control-board: answer in your own session as usual; the board reads your transcript. Do not agent-send to control-board.)"; const REPLY_TIMEOUT_MS = 15000; // Decide and, when allowed, send. Returns { status, body } for the HTTP layer. // Refusals (4xx) happen before the tool runs and carry { error }. Once the // tool has run the answer is 200 with delivered true/false, the exit code and // both output streams verbatim, whatever the code was. export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) { if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id /" } }; if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } }; if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } }; const rec = index.sessions.find((r) => seenKey(r) === key); if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } }; if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } }; const reg = rec.registered; if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } }; if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } }; if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } }; const session = reg.tmux.session; const socket = reg.tmux.socket || null; const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`]; if (socket) args.push("-L", socket); args.push("-m", `${text}\n${REPLY_TRAILER}`); // The registration says which socket the seat is on. A launcher-exported // MOSAIC_TMUX_SOCKET in this server's own environment must not override it. const env = { ...process.env }; delete env.MOSAIC_TMUX_SOCKET; const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }); if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } }; const exitCode = r.status; return { status: 200, body: { delivered: exitCode === 0, exitCode, signal: r.signal ?? null, stdout: String(r.stdout ?? ""), stderr: String(r.stderr ?? ""), agent: key, session, socket, sentAt: now().toISOString(), }, }; } function sendJson(res, status, body) { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body) + "\n"); } function readJsonBody(req) { return new Promise((resolvePromise, reject) => { const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase(); if (type !== "application/json") return reject(new Error("Content-Type must be application/json")); const chunks = []; let size = 0; req.on("data", (c) => { size += c.length; if (size > MAX_BODY) { req.destroy(); reject(new Error(`body larger than ${MAX_BODY} bytes`)); return; } chunks.push(c); }); req.on("end", () => { try { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object"); resolvePromise(parsed); } catch (err) { reject(new Error(`invalid JSON body: ${err.message}`)); } }); req.on("error", reject); }); } 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."); } // Returns the refusal text for a request that did not come from this server's // own loopback origin, or null. Uses the port the connection arrived on. export function foreignRequest(req) { let authority; try { authority = new URL(`http://${req.headers.host}`); } catch { return "non-local Host refused"; } const plain = !authority.username && !authority.password && authority.pathname === "/" && !authority.search && !authority.hash; if (!plain || !isLoopbackHost(authority.hostname.replace(/^\[|\]$/g, "")) || Number(authority.port || 80) !== req.socket.localPort) return "non-local Host refused"; if (req.headers.origin !== undefined && req.headers.origin !== `http://${req.headers.host}`) return "cross-origin request refused"; return null; } // HTTP status for each reader refusal. The body always carries the code. The // tests hold every code the reader can raise to an entry here. export const 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, "unsupported-harness": 422, "not-a-pi-session": 422, "too-large": 422, "unknown-actor": 403, "unsupported-purpose": 422, }; const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; function sendConversationJson(res, status, body) { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" }); res.end(JSON.stringify(body) + "\n"); } // GET /api/conversation: id is required; branch and cursor are optional; any // other, repeated or malformed parameter is a 400. function conversationQuery(url) { const out = {}; for (const key of new Set(url.searchParams.keys())) { const values = url.searchParams.getAll(key); if (!["id", "branch", "cursor"].includes(key)) return { error: `unknown parameter: ${key}` }; if (values.length !== 1 || !QUERY_VALUE.test(values[0])) return { error: `invalid ${key}` }; out[key] = values[0]; } if (!out.id) return { error: "id is required" }; if (out.cursor && !out.branch) return { error: "a cursor call repeats the page's branch" }; return out; } export function conversationResponse(reader, url) { if (url.pathname === "/api/conversations") { if ([...url.searchParams.keys()].length) return { status: 400, body: { error: "no parameters are accepted" } }; return { status: 200, body: reader.catalogue() }; } const query = conversationQuery(url); if (query.error) return { status: 400, body: { error: query.error } }; const out = query.cursor ? reader.next({ cursor: query.cursor, conversation: query.id, branch: query.branch }) : reader.open({ conversation: query.id, branch: query.branch ?? null }); if (out.ok) return { status: 200, body: out }; return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } }; } 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, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) { const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) }); return createHttpServer((req, res) => { const refused = foreignRequest(req); if (refused) { req.resume(); return sendJson(res, 403, { error: refused }); } const url = new URL(req.url, "http://localhost"); if (req.method === "POST" && url.pathname === "/api/reply") { return readJsonBody(req) .then((body) => { let index; try { index = rescan(); } catch (err) { return sendJson(res, 500, { error: err.message }); } const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now }); sendJson(res, out.status, out.body); }) .catch((err) => sendJson(res, 400, { error: err.message })); } if (req.method === "POST" && url.pathname === "/api/seen") { return readJsonBody(req) .then((body) => { try { markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true }); } catch (err) { return sendJson(res, 400, { error: err.message }); } try { sendJson(res, 200, rescan()); } catch (err) { sendJson(res, 500, { error: err.message }); } }) .catch((err) => sendJson(res, 400, { error: err.message })); } 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 = rescan(); } 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 === "/api/conversations" || url.pathname === "/api/conversation") { let out; try { out = conversationResponse(reader, url); } catch (err) { process.stderr.write(`conversation read failed: ${err.message}\n`); out = { status: 500, body: { error: "conversation read failed" } }; } return sendConversationJson(res, out.status, out.body); } 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); }); }); }