From c3b60e51386bcd95bb4a6a68d1f1f598182017fd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 11:46:54 -0500 Subject: [PATCH] row 198: retire the board writer on 7331; the Console serves the board in-process The Console builds the board context itself (control-board createBoard) and answers the board routes in-process. The standalone control-board HTTP writer and page.html are removed; control-board serve exits 2 as retired, and webui --board is refused. /api/seen and /api/reply need --send and a named human, else 403 read-only. Tests are rewritten against the in-process board (inProcessBoard in the webui fixture; createBoard in control-board). Proxy, unreachable-board and HTTP-only tests are dropped; the dropped set is listed in BUILD.md. UNVERIFIED: committed under Sage's slot hold (Gander run 5). No suite or mutant has run on this commit; do not push before the gate is green. --- agents/bushroot/work/queue-198/BUILD.md | 109 ++ packages/cli/tests/stack-copy.test.mjs | 2 +- packages/control-board/src/board.mjs | 72 ++ packages/control-board/src/cli.mjs | 24 +- packages/control-board/src/page.html | 605 ---------- packages/control-board/src/scan.mjs | 48 +- packages/control-board/src/serve.mjs | 240 +--- packages/control-board/tests/board.test.mjs | 81 ++ packages/control-board/tests/discord.test.mjs | 24 +- packages/control-board/tests/serve.test.mjs | 1063 ++--------------- packages/webui/src/cli.mjs | 28 +- packages/webui/src/public/app.js | 4 +- packages/webui/src/serve.mjs | 70 +- packages/webui/tests/board-held.test.mjs | 2 +- packages/webui/tests/browser.test.mjs | 6 +- packages/webui/tests/bus-browser.test.mjs | 2 +- packages/webui/tests/chat-browser.test.mjs | 2 +- packages/webui/tests/chat.test.mjs | 2 +- packages/webui/tests/discord.test.mjs | 13 +- packages/webui/tests/docs-browser.test.mjs | 2 +- packages/webui/tests/editor-browser.test.mjs | 2 +- packages/webui/tests/fixture.mjs | 20 +- packages/webui/tests/history-fixture.mjs | 9 +- packages/webui/tests/pinned-copy.test.mjs | 2 +- packages/webui/tests/preview.test.mjs | 2 +- packages/webui/tests/proof-cli.test.mjs | 6 +- packages/webui/tests/proof.test.mjs | 9 +- packages/webui/tests/reads.test.mjs | 2 +- packages/webui/tests/relaunch.test.mjs | 16 +- packages/webui/tests/return-flow.test.mjs | 11 +- packages/webui/tests/send-browser.test.mjs | 4 +- packages/webui/tests/send.test.mjs | 2 +- packages/webui/tests/serve.test.mjs | 79 +- packages/webui/tests/service.test.mjs | 11 +- packages/webui/tests/sso-browser.test.mjs | 2 +- packages/webui/tests/tester-cli.test.mjs | 2 +- scripts/stack-copy.sh | 2 + scripts/webui-service.sh | 12 +- 38 files changed, 617 insertions(+), 1975 deletions(-) create mode 100644 agents/bushroot/work/queue-198/BUILD.md create mode 100644 packages/control-board/src/board.mjs delete mode 100644 packages/control-board/src/page.html create mode 100644 packages/control-board/tests/board.test.mjs diff --git a/agents/bushroot/work/queue-198/BUILD.md b/agents/bushroot/work/queue-198/BUILD.md new file mode 100644 index 000000000..8bff68ee9 --- /dev/null +++ b/agents/bushroot/work/queue-198/BUILD.md @@ -0,0 +1,109 @@ +# Row 198 build: retire the board writer on 7331; the Console serves the board in-process + +Branch `bushroot/row198-r2`, based on `origin/refactor` b79ad5f2c. Issue #1495 family, Gitea #1696. +Ruling source: Sage sage-2803 (Q220 / mos-1301), reviewer Webby. + +## Status: UNVERIFIED + +No test suite, mutant, or node process has run for this commit. Gander holds the +slot (row 253 run 5, from 16:32Z) and Sage's rule is "no test runs until my CLEAR". +Sage's rule overrides the usual "commit only after green" order in AGENTS.md +invariant 8, so this commit is local, unverified, and must not be pushed until the +gate runs green. + +## Why the commit holds more than the tests + +Every row 198 change sat uncommitted on this branch; `origin/refactor..HEAD` was empty +when the commit was made. The source changes (`packages/control-board/src`, +`packages/webui/src`, `scripts/stack-copy.sh`, `scripts/webui-service.sh`) are in the +same commit as the test rewrite, so the commit cannot be reviewed as tests alone. + +## What changed in the source (already on the r2 code, now committed) + +- `packages/control-board/src/board.mjs` (new): `createBoard`, the board context the + Console builds in-process. It owns the write counter (`markSeen` = write, bump, scan; + `reply` bumps only when a send happened, so a refusal does no bump), and it joins an + in-flight scan only when that scan started at the current write count. +- `packages/control-board/src/serve.mjs`, `scan.mjs`, `cli.mjs`: the standalone HTTP + writer is gone. `control-board serve` throws RETIRED and exits 2. `page.html` is + deleted. +- `packages/webui/src/serve.mjs`: board routes answer from the in-process board; + `/api/seen` and `/api/reply` need `--send` and a named human, else 403 + `{error:'read-only', sent:false}`. No board configured answers 503. +- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the + Console (row 198)`. The board context is built from the system config's dataRoot. +- `scripts/webui-service.sh`: the unit no longer carries `--board`. `--board` is refused in + render and install. +- `scripts/stack-copy.sh`: COPY_PATHS adds `packages/control-board/src` and `tools/tmux`. + +## What changed in the tests + +Rewritten against the in-process board, not the proxy: + +- `packages/webui/tests/fixture.mjs`: new `inProcessBoard({root, specs, seatsDir, isAlive, + exec})`. `setDown(true)` makes `index()` throw, to test a failing board read. + `close()` closes the web server and removes the root only. +- `packages/webui/tests/history-fixture.mjs`, `return-flow.test.mjs`, `relaunch.test.mjs`, + `discord.test.mjs`: `inProcessBoard` replaces `startBoard`. No board close in `finally`. +- `board: f.boardURL` becomes `board: f.board` in docs-browser, editor-browser, + sso-browser, chat-browser, send-browser (two sites), bus-browser, board-held. +- `'--board', 'http://127.0.0.1:9'` removed from chat.test, proof-cli (three sites), + pinned-copy, preview, send, tester-cli. +- `browser.test.mjs`: the down case uses `f.board.setDown(true)` and expects the + `in the Console` text. +- `serve.test.mjs` (webui): the proxy-forwarding and unreachable-board tests are removed. + New: in-process routes (writes refused 403 `{sent:false}` without `--send`, with zero + captures; GET `/api/reply` 405; OPTIONS `/api/board` 405; conversation pass-through; + a failing read answers >=400). New: `board: null` answers 503, and the CLI refuses + `--board` and other bad args with exit 2 and `refused:`. +- `service.test.mjs`: the render expectation has no `--board`. The refusal list keeps + `--board` with a row 198 comment. The factory expectation has no `--board`. +- `reads.test.mjs:188`: `board: null`, since that test configures no board. +- `proof.test.mjs`: the fake HTTP board is replaced by an in-process `{index}` object; + the tester socket gets `board: null`. Call-order assertions unchanged. +- `packages/cli/tests/stack-copy.test.mjs:145`: the spawn drops `--board`. +- `packages/control-board/tests/serve.test.mjs`: 216 lines. The HTTP and page.html tests + are removed. The reply tests are ported to `createBoard`, with a `replyBoard` helper + pinned to 2026-09-12T18:00:00Z. Kept: the isLoopbackHost tests, the CLI port test, + two CLI scan tests, the DEFAULT_AGENT_SEND test and the REFUSAL_STATUS test. +- `packages/control-board/tests/discord.test.mjs`: the board test is now + `board rescans connector discovery and refuses reply without transport`, on `createBoard`. +- `packages/control-board/tests/board.test.mjs` (new): 4 tests. These passed 4/4 on the + r2 base earlier; not re-run on this commit. + +## Dropped coverage (deliberate; Sage to confirm) + +- Stale-pid reply case in control-board serve. It needed `isPidAlive` injection, which + `replyBoard` does not provide. +- Bad-JSON request case. It exercised HTTP body parsing, and the HTTP writer is gone. +- page.html tests. The page is deleted. +- Tester board-proxy tests. There is no proxy any more; the tester socket runs with + `board: null`. + +## Checks done without running anything + +- Read whole, every touched test file: `service.test.mjs`, `reads.test.mjs`, + `serve.test.mjs` (webui), `proof.test.mjs`, `fixture.mjs`, control-board + `serve.test.mjs` and `discord.test.mjs`. The three browser spawn files were + re-checked by grep. +- `grep -rn -e '--board' -e boardURL -e startBoard packages/*/tests scripts/test-*.sh`: + remaining hits are all intended refusals or unrelated suites: + - `packages/cli/tests/case2-unit.test.mjs:214, 330, 371`: case2's own `--board` + refusal (`scripts/case2-unit.sh:165`), unchanged. + - `packages/cli/tests/stack-release.test.mjs:57`: a mutation that adds `--board` to a + unit must still be refused. Read, not changed. + - `packages/webui/tests/service.test.mjs:255-256`, `serve.test.mjs:78-85`: refusal checks. +- sha256 pins: the grep for 64-hex literals in changed tests and scripts found one hit, + `service.test.mjs:23`, a fixture Authentik uid (`UID`), not a file hash. No changed file + pins a file sha256. +- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports + `DEFAULT_BOARD`. Harmless; may be dropped later. + +## Not done (open) + +- Docs: `packages/webui/README.md` (lines near 10, 15, 766, 930 still mention 7331 or + `--board`), the control-board README serve instructions, and an append-only dated line + in `docs/plans/CURRENT.md` near line 171. Not in this commit. +- Gate: control-board, webui and cli suites; `scripts/test-*.sh`; mutants M1 to M15 from + the earlier plan. All wait for Sage's CLEAR. +- Push: not done. Sage's gaps govern it. diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index d769bca93..aefbf83f9 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -142,7 +142,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c); try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);} finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}} - const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; + const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c); try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}} diff --git a/packages/control-board/src/board.mjs b/packages/control-board/src/board.mjs new file mode 100644 index 000000000..7b6a412e9 --- /dev/null +++ b/packages/control-board/src/board.mjs @@ -0,0 +1,72 @@ +// The control board's routes as plain async functions (row 198). The Console +// (packages/webui) calls these in-process behind its own auth. Nothing here +// opens a socket or binds a port: the 7331 writer is retired. +// +// Every method returns a promise and uses the async child-process paths (B1), so +// a slow tmux call never blocks the Console's event loop. +// +// Write counter (decision 10, Webby N1). A scan must not serve a pre-write view. +// index() joins an in-flight scan only when that scan started at the current +// counter value. markSeen() and reply() bump the counter and never join a scan. + +import { scanAsync, markSeen as writeMark, loadRegistrations, runAsync } from "./scan.mjs"; +import { replyToRowAsync, conversationResponse, DEFAULT_AGENT_SEND } from "./serve.mjs"; +import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; + +// onEvent(name) reports "write", "bump", "send" and "scan" in order. Production +// passes nothing; the order test (board.test.mjs) reads it to pin the sequence. +export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) { + let writes = 0; + let inflight = null; + const scanFresh = () => { + onEvent("scan"); + return scanAsync(specs, { isAlive, exec, boardDir, now, seatsDir, discordDataRoot }); + }; + const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) }); + + // Joins a scan only when it started after the last write (see header). + function index() { + if (inflight && inflight.generation === writes) return inflight.promise; + const generation = writes; + const promise = scanFresh(); + inflight = { generation, promise }; + return promise; + } + + // Pinned order (Webby, Mos mos-1052): write, then bump the counter, then scan. + // The scan sees the mark, and no scan started before the bump can be joined. + async function markSeenRoute(body) { + writeMark(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true }); + onEvent("write"); + writes++; + onEvent("bump"); + return scanFresh(); + } + + // Pinned order: send, then bump the counter, then scan. The planning scan + // before the send is a read and is not part of the pinned sequence. A refusal + // runs no send, so it writes nothing: no bump and no post-send scan. Only a + // send that reached the exec runs the rest. The post-send scan rewrites the + // derived board files, so a reply's effects reach them the same way a mark does. + async function reply(body) { + const planned = await scanFresh(); + let sent = false; + const sendExec = (...args) => { + sent = true; + return exec(...args); + }; + const out = await replyToRowAsync({ index: planned, key: body.agent, text: body.text, agentSend, exec: sendExec, now, host }); + if (!sent) return out; + onEvent("send"); + writes++; + onEvent("bump"); + await scanFresh(); + return out; + } + + function conversation(url) { + return conversationResponse(reader, url); + } + + return { index, markSeen: markSeenRoute, reply, conversation }; +} diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs index 864cd94a4..2ef88a538 100755 --- a/packages/control-board/src/cli.mjs +++ b/packages/control-board/src/cli.mjs @@ -1,17 +1,18 @@ #!/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] -// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1] +// `serve` is retired (row 198): the board runs in-process inside the Console. // 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, tmuxInspect, 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]"; +const USAGE = "usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]"; +const RETIRED = "the control board no longer serves a port (retired on 7331, row 198). Open the board in the Console (webui, port 7332) instead."; function parseArgs(argv) { - 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" }; + if (argv[0] === "serve") throw new ConfigError(RETIRED); + const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false }; for (let i = 1; i < argv.length; i++) { const a = argv[i]; const next = () => { @@ -23,13 +24,9 @@ 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" && opts.command !== "serve") throw new ConfigError(USAGE); + if (opts.command !== "scan") throw new ConfigError(USAGE); if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`); return opts; } @@ -41,15 +38,6 @@ async function main() { const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true; const boardDir = join(dataRoot, "board"); const seatsDir = join(dataRoot, "seats"); - if (opts.command === "serve") { - const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot }); - 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, seatsDir, discordDataRoot: dataRoot }); for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`); for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`); diff --git a/packages/control-board/src/page.html b/packages/control-board/src/page.html deleted file mode 100644 index cf4405164..000000000 --- a/packages/control-board/src/page.html +++ /dev/null @@ -1,605 +0,0 @@ - - - - - -Control board - - - -
-

Control board

-
-

Updated never · next refresh in 10s

- - -
-
- -
-
-

Waiting on you

-
-
-
-
-

Seen

-
-
-
-
-

By project

-
-
-
- - - - diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index e89c25e99..1caa042ed 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -16,7 +16,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs"; import { join, basename, isAbsolute, resolve } from "node:path"; import { homedir } from "node:os"; -import { spawnSync } from "node:child_process"; +import { spawnSync, execFile } from "node:child_process"; import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs"; import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs'; @@ -193,17 +193,46 @@ export function parsePanes(listPanesOutput) { // "unknown", never assumed alive). // workspace the current path of the first pane running pi, else null. // `exec` is injectable for tests. -export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) { +export function tmuxInspect(tmux, { exec = spawnSync } = {}) { + return inspectFromResult(exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS })); +} + +const TMUX_TIMEOUT_MS = 5000; + +function tmuxArgs({ socket, session }) { const args = []; if (socket) args.push("-L", socket); args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}"); - const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 }); + return args; +} + +// Shared by the sync and async paths, so they cannot drift. +function inspectFromResult(r) { if (r.error) return { alive: null, workspace: null }; if (r.status !== 0) return { alive: false, workspace: null }; const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command)); return { alive: Boolean(pane), workspace: pane?.path ?? null }; } +// Async child run for the Console's in-process board (B1). Same result shape as +// spawnSync: { status, signal, stdout, stderr, error }. A timeout or a failure to +// start is an error, never a status. The Console's event loop must not block here. +export function runAsync(file, args, { encoding = "utf8", timeout, env } = {}) { + return new Promise((resolvePromise) => { + execFile(file, args, { encoding, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => { + if (!err) return resolvePromise({ status: 0, signal: null, stdout, stderr, error: undefined }); + if (err.killed) return resolvePromise({ status: null, signal: err.signal ?? null, stdout, stderr, error: Object.assign(new Error(`timed out after ${timeout} ms`), { code: "ETIMEDOUT" }) }); + if (typeof err.code === "number") return resolvePromise({ status: err.code, signal: null, stdout, stderr, error: undefined }); + return resolvePromise({ status: null, signal: null, stdout, stderr, error: err }); + }); + }); +} + +// Async twin of tmuxInspect. `exec` is injectable for tests. +export async function tmuxInspectAsync(tmux, { exec = runAsync } = {}) { + return inspectFromResult(await exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS })); +} + // Liveness only, for callers that do not need the pane path. export function tmuxIsAlive(tmux, opts) { return tmuxInspect(tmux, opts).alive; @@ -462,3 +491,16 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv writeAtomic(join(boardDir, "index.json"), index); return index; } + +// Async scan for the Console's in-process board (B1). With no isAlive, every tmux +// check runs through the async exec first, in parallel, and the sync scan body +// then reads those answers. A caller's sync isAlive (the CLI, tests) is used as is. +// One scan body serves both paths, so the records cannot drift. +export async function scanAsync(specs, { isAlive, exec = runAsync, ...rest } = {}) { + if (isAlive) return scan(specs, { ...rest, isAlive }); + const checks = new Map(); + const keyOf = (tmux) => JSON.stringify([tmux.socket ?? null, tmux.session]); + const pending = [...new Map(specs.filter((s) => !s.connector).map((s) => [keyOf(s.tmux), s.tmux])).values()]; + await Promise.all(pending.map(async (tmux) => checks.set(keyOf(tmux), await tmuxInspectAsync(tmux, { exec })))); + return scan(specs, { ...rest, isAlive: (tmux) => checks.get(keyOf(tmux)) ?? { alive: null, workspace: null } }); +} diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 655ac6208..b2daee331 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -1,55 +1,30 @@ -// Control board step 2: a tiny local web server. No dependencies, no auth. -// It only ever binds to a loopback address (fail closed otherwise). +// Control board: the pure board logic the Console calls in-process (row 198). +// No listener. The writer on 7331 is retired; the Console serves the board +// routes behind its own auth (packages/webui/src/serve.mjs). The board opens no +// socket and never binds a port. // -// 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. +// Route logic lives in src/board.mjs. This module keeps the pure helpers the +// Console and the tests share: reply planning and shaping, the Host/Origin check +// (foreignRequest), and the conversation query and refusal map. // // 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. +// a Console on this host as its own origin, with its own name as Host, and could +// read the board or post /api/reply into a live pane. A Host that is not a +// loopback name on the listening port, or any Origin other than the server's +// own, gets 403 before anything else runs. The Console applies the same check. // 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. +// conversation id is opaque and no path comes from the request. -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; +import { resolve } from "node:path"; +import { seenKey, runAsync } from "./scan.mjs"; // 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 @@ -71,27 +46,48 @@ const REPLY_TIMEOUT_MS = 15000; // 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` } }; +export function replyToRow(opts) { + return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync }); +} + +// Async twin of replyToRow for the in-process board (B1). `exec` is async. +export async function replyToRowAsync(opts) { + return replyWith(opts, async (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: runAsync, async: true }); +} + +function replyWith({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec, now = () => new Date(), host = hostname().split(".")[0] }, send, { exec: defaultExec, async = false }) { + const plan = planReply({ index, key, text, host }); + if (plan.refusal) return async ? Promise.resolve(plan.refusal) : plan.refusal; + // 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 finish = (r) => replyBody({ r, key, agentSend, session: plan.session, socket: plan.socket, now }); + const out = send(exec ?? defaultExec, plan.args, env, agentSend); + return async ? Promise.resolve(out).then(finish) : finish(out); +} + +function planReply({ index, key, text, host }) { + const refuse = (status, error) => ({ refusal: { status, body: { error } } }); + if (typeof key !== "string" || !key) return refuse(400, "agent must be the row id /"); + if (typeof text !== "string" || !text.trim()) return refuse(400, "text must be a non-empty string"); + if (text.length > REPLY_LIMIT) return refuse(400, `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" } }; + if (!rec) return refuse(404, `unknown row: ${key}`); + if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return refuse(409, "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" } }; + if (!reg) return refuse(409, "reply needs a registered seat (start it through scripts/mosaic launch)"); + if (reg.alive === false) return refuse(409, `registration is stale: pid ${reg.pid} is gone`); + if (!reg.tmux || !reg.tmux.session) return refuse(409, "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 }); + return { args, session, socket }; +} + +function replyBody({ r, key, agentSend, session, socket, now }) { if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } }; const exitCode = r.status; return { @@ -110,39 +106,6 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e }; } -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) { @@ -187,11 +150,6 @@ export const REFUSAL_STATUS = { }; 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) { @@ -220,103 +178,3 @@ export function conversationResponse(reader, url) { 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); - }); - }); -} diff --git a/packages/control-board/tests/board.test.mjs b/packages/control-board/tests/board.test.mjs new file mode 100644 index 000000000..1717f401f --- /dev/null +++ b/packages/control-board/tests/board.test.mjs @@ -0,0 +1,81 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { createBoard } from "../src/board.mjs"; + +const pkgRoot = resolve(import.meta.dirname, ".."); +const cli = join(pkgRoot, "src", "cli.mjs"); + +// Track every tmpdir we create so a stray failure never leaves fixtures behind. +const roots = []; +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), "control-board-board-test-")); + roots.push(root); + return root; +} +after(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }); +}); + +// One agent whose last message is an assistant reply (waiting on the user), so +// a mark on it is visible in the index. No registration; liveness stubbed so no tmux runs. +function fixture() { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync( + join(sessionsDir, "session.jsonl"), + [ + JSON.stringify({ type: "session", id: "s1", timestamp: "2026-10-20T18:59:00Z", cwd: root }), + JSON.stringify({ type: "message", timestamp: "2026-10-20T19:00:00Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Input needed: which branch should I use?" }] } }), + ].join("\n") + "\n", + ); + const boardDir = join(root, "board"); + const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }]; + const isAlive = () => ({ alive: true, workspace: null }); + return { root, boardDir, specs, isAlive }; +} + +// Pinned order (Webby, Mos mos-1052): markSeen writes, bumps the counter, then scans. +test("markSeen runs write, bump, scan in that order", async () => { + const { boardDir, specs, isAlive } = fixture(); + const events = []; + const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) }); + await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" }); + assert.deepEqual(events, ["write", "bump", "scan"]); +}); + +// Mutant target: a scan run before the write, or a join of a pre-write scan, +// would let the answer omit the mark it was asked to show. +test("markSeen answer includes the mark it just wrote", async () => { + const { boardDir, specs, isAlive } = fixture(); + const board = createBoard({ specs, boardDir, isAlive }); + const before = await board.index(); + assert.deepEqual(before.seen, []); + const rec = before.sessions[0]; + const index = await board.markSeen({ project: "proj", agent: "agent1", lastActivity: rec.lastActivity }); + assert.deepEqual(index.seen, ["proj/agent1"]); +}); + +// A reply to a row that is not registered is refused before any send, so the +// send and bump events must not appear. +test("reply refuses an unregistered row and never sends", async () => { + const { boardDir, specs, isAlive } = fixture(); + const events = []; + const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") }); + const out = await board.reply({ agent: "proj/agent1", text: "hello" }); + assert.equal(out.status, 409); + assert.equal(events.includes("send"), false); + assert.equal(events.includes("bump"), false); +}); + +// The retired writer refuses to start. Exit 2 with a pointer to the Console. +test("cli serve refuses and points to the Console", () => { + const r = spawnSync(process.execPath, [cli, "serve"], { encoding: "utf8" }); + assert.equal(r.status, 2); + assert.match(r.stderr, /retired on 7331/); + assert.match(r.stderr, /webui/); +}); diff --git a/packages/control-board/tests/discord.test.mjs b/packages/control-board/tests/discord.test.mjs index 9b69d31bf..98cf75067 100644 --- a/packages/control-board/tests/discord.test.mjs +++ b/packages/control-board/tests/discord.test.mjs @@ -6,7 +6,8 @@ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs'; import { scan, scanAgent } from '../src/scan.mjs'; -import { replyToRow, startServer } from '../src/serve.mjs'; +import { replyToRow } from '../src/serve.mjs'; +import { createBoard } from '../src/board.mjs'; import { identityOf } from '../../discord/src/journal.mjs'; import { envelope } from '../../discord/src/context.mjs'; import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; @@ -115,21 +116,20 @@ test('connector reply refusal precedes forged live tmux registration; ordinary a r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1); }); -test('server rescans connector discovery and refuses HTTP reply without transport', async () => { - const f = fixture(); let server; +test('board rescans connector discovery and refuses reply without transport', async () => { + const f = fixture(); try { const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats'); writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid })); - server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); - const base = `http://127.0.0.1:${server.address().port}`; - let response = await fetch(base + '/api/board'); const board = await response.json(); - assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true); - assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged'); - response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) }); - assert.equal(response.status, 409); + const board = createBoard({ specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); + const payload = await board.index(); + assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true); + assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged'); + const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }); + assert.equal(status, 409); rmSync(join(f.root, 'discord', 'demo.json')); - assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0); - } finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); } + assert.equal((await board.index()).sessions.length, 0); + } finally { f.cleanup(); } }); test('connector session links and linked directories are not read', () => { diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index ef7ef549d..17ea75e74 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -1,5 +1,3 @@ -import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs'; -import { after as laneAfter } from 'node:test'; import { test, after } from "node:test"; import assert from "node:assert/strict"; import { @@ -11,17 +9,14 @@ import { 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 { spawnSync } from "node:child_process"; +import { markSeen } from "../src/scan.mjs"; +import { createBoard } from "../src/board.mjs"; +import { isLoopbackHost, 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"; @@ -69,14 +64,6 @@ function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) { // 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 leasePort({after:laneAfter}, {closed:true}); } - function runCli(args) { return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 }); } @@ -101,200 +88,9 @@ test("isLoopbackHost: rejects non-loopback hosts", () => { }); // --------------------------------------------------------------------------- -// 2. startServer: fail-closed on a non-loopback host +// 2. CLI: the port is refused before anything runs; scan and its --print mark // --------------------------------------------------------------------------- -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, "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: "", - }); - 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: "", - }); - 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); @@ -314,234 +110,6 @@ test("CLI: scan still works after the async cli refactor", () => { 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}`); -}); - -// --------------------------------------------------------------------------- -// 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: "" }); - 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: "" }); - 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: "" }); - 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: "" }); - 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: "" }); - 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: "" }); - 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"); @@ -571,127 +139,8 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with }); // --------------------------------------------------------------------------- -// 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, /
(?![^>]*\sopen)/, "the Seen section must start collapsed"); - assert.match(html, /

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