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.
This commit is contained in:
jason.woltje committed 2026-10-22 11:51:18 -05:00
1 parent aca16404cf
commit c3b60e5138
38 files changed
+617 -1975

No files matched your search

+109
View File
@@ -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.
+1 -1
View File
@@ -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); 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);} 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;}} 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); 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);} 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;}} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
+72
View File
@@ -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 };
}
+6 -18
View File
@@ -1,17 +1,18 @@
#!/usr/bin/env node #!/usr/bin/env node
// Usage: // 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 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. // Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path"; import { join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs"; 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) { 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++) { for (let i = 1; i < argv.length; i++) {
const a = argv[i]; const a = argv[i];
const next = () => { const next = () => {
@@ -23,13 +24,9 @@ function parseArgs(argv) {
else if (a === "--fleet") opts.fleet = next(); else if (a === "--fleet") opts.fleet = next();
else if (a === "--liveness") opts.liveness = next(); else if (a === "--liveness") opts.liveness = next();
else if (a === "--print") opts.print = true; 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}`); 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}`); if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts; return opts;
} }
@@ -41,15 +38,6 @@ async function main() {
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true; const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board"); const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats"); 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 }); 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.discoveryErrors) process.stderr.write(`${line}\n`);
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`); for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
-605
View File
@@ -1,605 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control board</title>
<style>
:root{
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--radius:8px;--radius-lg:14px;
}
@media (prefers-color-scheme: dark){
:root{
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
}
}
*,*::before,*::after{box-sizing:border-box}
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
body{margin:0 auto;max-width:1100px;padding:16px}
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.status-line{margin:0;color:var(--muted);font-size:.9rem}
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
.btn:hover{background:var(--raised)}
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
.empty{color:var(--muted);font-style:italic}
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
#seenDetails summary::-webkit-details-marker{display:none}
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
#seenDetails[open] summary::before{content:"\25BE"}
#seenDetails summary h2{display:inline;margin:0}
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
tbody tr:last-child td{border-bottom:none}
tr.is-offline{opacity:.55}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
border-radius:99px;border:1px solid transparent;white-space:nowrap}
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
.badge-muted{color:var(--muted);background:var(--raised)}
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
cursor:pointer;text-align:left}
.row-toggle:hover{text-decoration:underline}
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
.seen-toggle:hover{border-color:var(--action)}
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
.detail-list dd{margin:0;overflow-wrap:anywhere}
.project-group{margin-bottom:20px}
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
</style>
</head>
<body>
<header class="page-head">
<h1>Control board</h1>
<div class="head-controls">
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
<button id="refreshBtn" class="btn" type="button">Refresh</button>
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
</div>
</header>
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
<main id="main">
<section aria-labelledby="waiting-h">
<h2 id="waiting-h">Waiting on you</h2>
<div id="waitingBody"></div>
</section>
<section aria-labelledby="seen-h">
<details id="seenDetails">
<summary><h2 id="seen-h">Seen</h2></summary>
<div id="seenBody"></div>
</details>
</section>
<section aria-labelledby="projects-h">
<h2 id="projects-h">By project</h2>
<div id="projectsBody"></div>
</section>
</main>
<footer id="footer" class="page-footer"></footer>
<script>
(function () {
"use strict";
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
var REFRESH_MS = 10000;
var headerStatus = document.getElementById("headerStatus");
var refreshBtn = document.getElementById("refreshBtn");
var pauseBtn = document.getElementById("pauseBtn");
var errorBanner = document.getElementById("errorBanner");
var waitingBody = document.getElementById("waitingBody");
var seenBody = document.getElementById("seenBody");
var seenHeading = document.getElementById("seen-h");
var projectsBody = document.getElementById("projectsBody");
var footer = document.getElementById("footer");
var main = document.getElementById("main");
var lastData = null;
var paused = false;
var fetching = false;
var timerId = null;
var secondsLeft = REFRESH_MS / 1000;
var hideOfflineState = {};
var hideSeenState = {};
// Open detail panels survive a refresh. Keyed by section, project and agent
// because the same agent can appear in both the waiting list and its group.
var openDetails = {};
var rowIdx = 0;
function esc(v) {
if (v === null || v === undefined) return "";
return String(v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function humanAge(s) {
if (s === null || s === undefined) return "—";
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
if (m < 60) return m + "m";
var h = Math.floor(m / 60);
if (h < 24) return h + "h";
return Math.floor(h / 24) + "d";
}
function timeAgo(iso) {
if (!iso) return "never";
var ms = Date.now() - Date.parse(iso);
if (Number.isNaN(ms)) return "never";
var s = Math.floor(ms / 1000);
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
var m = Math.floor(s / 60);
if (m < 60) return m + "m ago";
var h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
return Math.floor(h / 24) + "d ago";
}
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function badge(state) {
var cls = BADGE_CLASS[state] || "badge-muted";
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
}
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
// you have already marked. The mark clears itself when the agent writes again.
function seenControl(rec) {
var needsYou = rec.state === "waiting" || rec.state === "error";
if (!needsYou || !rec.lastActivity) return "";
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
if (rec.seen) {
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
}
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// Who set a registered task (#1511): shown only when the task on the row is
// the registered one (the scan gives null otherwise). It is what the
// caller claimed when it ran `mosaic seat task`, not a verified identity,
// and it never changes what the row lets you do.
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
// Reply-from-board (#1505): a box and Send only where the board has an
// address, i.e. a live registration naming a tmux session. Receipts and
// unsent drafts are kept per row id so a refresh does not lose them.
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
if (rec.connector) return false;
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
function replyReceipt(id) {
var r = replyReceipts[id];
if (!r) return "";
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
'<button type="submit">Send</button></form>' + replyReceipt(id);
}
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
if (reg.harness) parts.push("harness " + esc(reg.harness));
if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout");
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
return parts.join("; ");
}
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var notice = relaunchNotice(rec);
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
// Gate A fields: from the seat's registration (mosaic launch), else derived
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
var span = showProject ? 7 : 6;
var tmux = rec.tmux && rec.tmux.session
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
: "—";
var detail =
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
// Rows you marked Seen, in one place, so they can be found and put back.
// Collapsed by default; the open/closed choice survives a refresh.
function renderSeen(data) {
var list = (data.sessions || []).filter(function (r) { return r.seen; });
list.sort(function (a, b) {
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
seenHeading.textContent = "Seen (" + list.length + ")";
if (list.length === 0) {
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
seenBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function renderWaiting(data) {
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
list.sort(function (a, b) {
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
if (ae !== be) return ae - be;
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
if (list.length === 0) {
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
waitingBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function projectOrder(a, b) {
if (a === "fleet" && b === "fleet") return 0;
if (a === "fleet") return 1;
if (b === "fleet") return -1;
return a.localeCompare(b);
}
function renderProjects(data) {
var sessions = data.sessions || [];
var byProject = {};
sessions.forEach(function (r) {
(byProject[r.project] = byProject[r.project] || []).push(r);
});
var projects = Object.keys(byProject).sort(projectOrder);
if (projects.length === 0) {
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
return;
}
projectsBody.innerHTML = projects.map(function (project) {
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
if (!(project in hideSeenState)) hideSeenState[project] = true;
var hideOffline = hideOfflineState[project];
var hideSeen = hideSeenState[project];
var offlineHidden = 0, seenHidden = 0;
var visible = group.filter(function (r) {
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
if (hideSeen && r.seen) { seenHidden += 1; return false; }
return true;
});
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
var noteParts = [];
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
return (
'<div class="project-group">' +
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
'<div class="group-toggles">' +
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
"</div></div>" +
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
note + "</div>"
);
}).join("");
}
function renderFooter(data) {
var counts = data.counts || {};
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
parts += " · Seen " + ((data.seen || []).length);
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
// A refresh rebuilds the tables. If Jason is typing a reply, put the
// caret back where it was so the periodic refresh does not steal it.
function withReplyFocus(render) {
var active = document.activeElement;
var focus = active && active.classList && active.classList.contains("reply-text")
? { id: active.form.dataset.id, pos: active.selectionStart }
: null;
render();
if (!focus) return;
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
if (!input) return;
input.focus();
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
}
function renderAll() {
withReplyFocus(function () {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
});
}
function renderHeaderStatus() {
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
headerStatus.textContent = "Updated " + updated + " · " + refresh;
}
function showError(msg) {
errorBanner.hidden = false;
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
}
function hideError() {
errorBanner.hidden = true;
errorBanner.textContent = "";
}
function scheduleNextFetch() {
clearTimeout(timerId);
if (paused) return;
secondsLeft = REFRESH_MS / 1000;
timerId = setTimeout(runFetch, REFRESH_MS);
}
function runFetch() {
if (fetching) return;
fetching = true;
fetch("/api/board", { cache: "no-store" })
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("control board refresh failed", err);
showError(err && err.message ? err.message : "unknown error");
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function setPaused(p) {
paused = p;
pauseBtn.textContent = paused ? "Resume" : "Pause";
pauseBtn.setAttribute("aria-pressed", String(paused));
if (paused) clearTimeout(timerId);
else scheduleNextFetch();
renderHeaderStatus();
}
function postSeen(btn) {
if (fetching) return;
fetching = true;
btn.disabled = true;
fetch("/api/seen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("marking seen failed", err);
showError(err && err.message ? err.message : "unknown error");
btn.disabled = false;
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function postReply(form) {
var id = form.dataset.id;
var input = form.querySelector(".reply-text");
var text = input.value;
if (!text.trim()) return;
var sendBtn = form.querySelector("button");
sendBtn.disabled = true;
fetch("/api/reply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agent: id, text: text })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
replyReceipts[id] = body;
if (body.delivered) delete replyDrafts[id];
renderAll();
// The seat's log shows the effect; fetch now instead of waiting a tick.
if (body.delivered && !paused) runFetch();
})
.catch(function (err) {
console.error("reply failed", err);
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
renderAll();
});
}
main.addEventListener("submit", function (e) {
var form = e.target.closest(".reply-form");
if (!form) return;
e.preventDefault();
postReply(form);
});
main.addEventListener("input", function (e) {
var input = e.target.closest(".reply-text");
if (!input) return;
replyDrafts[input.form.dataset.id] = input.value;
});
main.addEventListener("click", function (e) {
var seenBtn = e.target.closest(".seen-toggle");
if (seenBtn) return postSeen(seenBtn);
var btn = e.target.closest(".row-toggle");
if (!btn) return;
var detail = document.getElementById("detail-" + btn.dataset.idx);
var expanded = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) delete openDetails[btn.dataset.key];
else openDetails[btn.dataset.key] = true;
});
main.addEventListener("change", function (e) {
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
if (!cb) return;
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
stateMap[cb.dataset.project] = cb.checked;
renderProjects(lastData);
});
refreshBtn.addEventListener("click", runFetch);
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
setInterval(function () {
if (!paused && secondsLeft > 0) secondsLeft -= 1;
renderHeaderStatus();
}, 1000);
renderHeaderStatus();
runFetch();
})();
</script>
</body>
</html>
+45 -3
View File
@@ -16,7 +16,7 @@
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs"; import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
import { join, basename, isAbsolute, resolve } from "node:path"; import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os"; 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 { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs'; import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
@@ -193,17 +193,46 @@ export function parsePanes(listPanesOutput) {
// "unknown", never assumed alive). // "unknown", never assumed alive).
// workspace the current path of the first pane running pi, else null. // workspace the current path of the first pane running pi, else null.
// `exec` is injectable for tests. // `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 = []; const args = [];
if (socket) args.push("-L", socket); if (socket) args.push("-L", socket);
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}"); 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.error) return { alive: null, workspace: null };
if (r.status !== 0) return { alive: false, workspace: null }; if (r.status !== 0) return { alive: false, workspace: null };
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command)); const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
return { alive: Boolean(pane), workspace: pane?.path ?? null }; 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. // Liveness only, for callers that do not need the pane path.
export function tmuxIsAlive(tmux, opts) { export function tmuxIsAlive(tmux, opts) {
return tmuxInspect(tmux, opts).alive; 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); writeAtomic(join(boardDir, "index.json"), index);
return 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 } });
}
+49 -191
View File
@@ -1,55 +1,30 @@
// Control board step 2: a tiny local web server. No dependencies, no auth. // Control board: the pure board logic the Console calls in-process (row 198).
// It only ever binds to a loopback address (fail closed otherwise). // 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) // Route logic lives in src/board.mjs. This module keeps the pure helpers the
// GET /api/board re-runs the scanner and returns index.json as JSON // Console and the tests share: reply planning and shaping, the Host/Origin check
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen // (foreignRequest), and the conversation query and refusal map.
// (or clears the mark with seen:false); rescans, returns index
// POST /api/reply {agent: "<project>/<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=<conversation>[&branch=<branch>][&cursor=<cursor>]
// one CHAT-01 history page: the first page of a branch (the
// default one without branch), or the next page (or a
// follow) for a cursor. A cursor call repeats the page's
// branch; without it the answer is 400. Refusals carry
// {error, refusal: {code, reconcile}}.
//
// POST requires Content-Type: application/json. A plain form post from another
// site in the browser cannot set that header without a CORS preflight, and this
// server answers no preflight, so a stray page cannot flip marks.
// //
// Every route first checks Host and Origin (#1507). Binding to loopback does // 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 // 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 // a Console on this host as its own origin, with its own name as Host, and could
// /api/board or post /api/reply into a live pane. A Host that is not a loopback // read the board or post /api/reply into a live pane. A Host that is not a
// name on this server's port, or any Origin other than this server's own, gets // loopback name on the listening port, or any Origin other than the server's
// 403 before anything else runs. Same check as packages/webui/src/serve.mjs. // own, gets 403 before anything else runs. The Console applies the same check.
// No CORS headers are ever sent. // 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 // The conversation routes read only: packages/conversation lists the repository
// specs' Pi session roots (never fleet or connector ones), opens files // specs' Pi session roots (never fleet or connector ones), opens files
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The // O_NOFOLLOW and writes nothing. Registrations are hints there too. The
// conversation id is opaque and no path comes from the request. Cursors live // conversation id is opaque and no path comes from the request.
// in this server's memory, bound to the one actor this unauthenticated
// loopback route has, local-operator.
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { isIP } from "node:net"; import { isIP } from "node:net";
import { hostname } from "node:os"; import { hostname } from "node:os";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs"; import { resolve } from "node:path";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; import { seenKey, runAsync } from "./scan.mjs";
const MAX_BODY = 4096;
// Reply-from-board (#1505). The board owns no transport: a reply is handed to // 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 // 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 // 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 // tool has run the answer is 200 with delivered true/false, the exit code and
// both output streams verbatim, whatever the code was. // 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] }) { export function replyToRow(opts) {
if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id <project>/<agent>" } }; return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync });
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` } };
// 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 <project>/<agent>");
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); const rec = index.sessions.find((r) => seenKey(r) === key);
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } }; if (!rec) return refuse(404, `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.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; const reg = rec.registered;
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } }; if (!reg) return refuse(409, "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.alive === false) return refuse(409, `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.tmux || !reg.tmux.session) return refuse(409, "registration has no tmux session to address");
const session = reg.tmux.session; const session = reg.tmux.session;
const socket = reg.tmux.socket || null; const socket = reg.tmux.socket || null;
const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`]; const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`];
if (socket) args.push("-L", socket); if (socket) args.push("-L", socket);
args.push("-m", `${text}\n${REPLY_TRAILER}`); args.push("-m", `${text}\n${REPLY_TRAILER}`);
// The registration says which socket the seat is on. A launcher-exported return { args, session, socket };
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it. }
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET; function replyBody({ r, key, agentSend, session, socket, now }) {
const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env });
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } }; if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
const exitCode = r.status; const exitCode = r.status;
return { 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"]); const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
export function isLoopbackHost(host) { 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}$/; 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 // GET /api/conversation: id is required; branch and cursor are optional; any
// other, repeated or malformed parameter is a 400. // other, repeated or malformed parameter is a 400.
function conversationQuery(url) { function conversationQuery(url) {
@@ -220,103 +178,3 @@ export function conversationResponse(reader, url) {
if (out.ok) return { status: 200, body: out }; 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 } } }; 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);
});
});
}
@@ -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/);
});
+12 -12
View File
@@ -6,7 +6,8 @@ import { spawnSync } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs'; import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
import { scan, scanAgent } from '../src/scan.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 { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs'; import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.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); 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 () => { test('board rescans connector discovery and refuses reply without transport', async () => {
const f = fixture(); let server; const f = fixture();
try { try {
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats'); 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 })); 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 board = createBoard({ 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}`; const payload = await board.index();
let response = await fetch(base + '/api/board'); const board = await response.json(); assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true); assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged'); const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' });
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(status, 409);
assert.equal(response.status, 409);
rmSync(join(f.root, 'discord', 'demo.json')); rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0); assert.equal((await board.index()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); } } finally { f.cleanup(); }
}); });
test('connector session links and linked directories are not read', () => { test('connector session links and linked directories are not read', () => {
File diff suppressed because it is too large. Load diff
+20 -8
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP. // MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs'; 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 { busCaller } from './bus.mjs';
import { startTesterSocket } from './tester-socket.mjs'; import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.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 { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs'; import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs'; 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'; 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 // Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
// agentWrite reaches only these. // agentWrite reaches only these.
@@ -22,11 +25,11 @@ const agentCaller = (call, verbs) => async (verb, args) => {
return call(verb, args); return call(verb, args);
}; };
const args = process.argv.slice(2); 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 { try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage); 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; let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) { while (args.length) {
const flag = args.shift(); const flag = args.shift();
@@ -36,12 +39,14 @@ try {
if (flag === '--sso') { ssoOn = true; continue; } if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; } if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket'); 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(); const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`); if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') { if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535'); if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value); options.port = Number(value);
} else if (flag === '--board') options.board = value; }
// Row 77 (#1571): the canonical checkout the queue read runs from, when // Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy. // serve runs from a pinned copy; everything else loads from the copy.
else if (flag === '--checkout') { else if (flag === '--checkout') {
@@ -99,6 +104,13 @@ try {
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`); if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not 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 }); 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 // 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. // provider that can't be reached starts degraded; one whose discovery is refused stops the start.
@@ -110,14 +122,14 @@ try {
} }
let server; let server;
try { 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; } } catch (err) { sso?.close(); throw err; }
let testerServer = null; let testerServer = null;
if (testerSocket) { if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI }); 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') }; const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try { 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, tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') }, processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
@@ -136,7 +148,7 @@ try {
const health = sso?.health() ?? {}; 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 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' : ''; 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(); }); for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) { } catch (err) {
console.error(`refused: ${err.message}`); console.error(`refused: ${err.message}`);
+2 -2
View File
@@ -7,7 +7,7 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only. // Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map(); const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer; 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 // 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). // page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false; let machine = false;
@@ -476,5 +476,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); }); 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. // 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);
})(); })();
+34 -36
View File
@@ -11,19 +11,9 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { chatHTTP } from './chat-http.mjs'; import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs'; import { processingHTTP } from './processing-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) { export function isLoopback(host) {
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.')); 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 root = resolve(import.meta.dirname, 'public');
const files = new Map([ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']], ['/', ['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. // 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 signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`; 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 }) { async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' }); if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
let to = null; 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 // `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. // 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. // `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 (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1'; if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); 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 && [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 listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener'); 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. // 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 }))) { 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'); 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 (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'); 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) => { const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer'); 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/')) { if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log }); 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. // 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' }); 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/')) { if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length); const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' }); 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 (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' }); 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}/`; 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 (!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/business') return consoleRead(res, () => reads.business());
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus()); 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))); return consoleRead(res, () => reads.row(Number(id)));
} }
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' }); 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). // `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. // `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 === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path); const file = files.get(path);
+1 -1
View File
@@ -72,7 +72,7 @@ async function setup(machine, fast = false) {
const reader = b0.bindReader({ business: 'demo' }); const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine }); 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.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280); 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)})})()`); 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)})})()`);
+3 -3
View File
@@ -109,12 +109,12 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#close").click()'); await b.evaluate('document.querySelector("#close").click()');
} }
// Empty and unreachable pages are tested as browser responses, never live data claims. // 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. // 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).
await new Promise(r => f.board.close(r)); f.board.setDown(true);
await b.evaluate('document.querySelector("#refresh").click()'); await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#error").hidden'); await wait('!document.querySelector("#error").hidden');
const error = await b.evaluate('document.querySelector("#error").textContent'); 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.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
assert.deepEqual(await b.evaluate('window.errors'), []); assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden'); await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
+1 -1
View File
@@ -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('\u001b'), 'the label excerpt keeps the ESC byte');
assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character'); assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character');
writeFileSync(fieldsFile, JSON.stringify(hostile)); 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; const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true }); 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)})})()`); 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)})})()`);
+1 -1
View File
@@ -41,7 +41,7 @@ const stub = `(() => {
})();`; })();`;
async function setup(t, suffix = '') { async function setup(t, suffix = '') {
const f = await fixture(); t.after(() => f.close()); 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()); const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub }); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`); await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
+1 -1
View File
@@ -294,7 +294,7 @@ test('configured Console process injects chat through its existing human transpo
const { fileURLToPath } = await import('node:url'); const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root); const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); 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'], '--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] }); { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => { t.after(async () => {
+6 -7
View File
@@ -3,16 +3,15 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs'; import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { browser } from './browser.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 () => { test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-')); const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
let board, web, b; let web, b;
try { try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true }); 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 }); 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. // #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
const seatsDir = join(root, 'seats'); 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' }); 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'); } }); const board = inProcessBoard({ root, specs: [], seatsDir, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); web = await startServer({ port: 0, board });
const base = `http://127.0.0.1:${web.address().port}`; const base = `http://127.0.0.1:${web.address().port}`;
const payload = await (await fetch(base + '/api/board')).json(); const payload = await (await fetch(base + '/api/board')).json();
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle'); 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); assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000); 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')})()`); 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")'); await wait('document.querySelector(".session-row")');
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/); assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/); 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")'); 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'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/); 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 }); }
}); });
+1 -1
View File
@@ -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 f = await fixture(), b = await browser();
const reads = { files: [], file: [] }; const reads = { files: [], file: [] };
const web = await startServer({ const web = await startServer({
port: 0, board: f.boardURL, port: 0, board: f.board,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) }, 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 })) }; }, 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' }; }, 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' }; },
+1 -1
View File
@@ -51,7 +51,7 @@ async function session(run) {
const reads = []; const reads = [];
const f = await fixture(), b = await browser(); const f = await fixture(), b = await browser();
const web = await startServer({ const web = await startServer({
port: 0, board: f.boardURL, port: 0, board: f.board,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) }, reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }), 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' }; }, 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' }; },
+13 -7
View File
@@ -1,12 +1,19 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs. // 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 { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; 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 { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); }); 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() { export async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-')); const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
const seatsDir = join(root, 'seats'), specs = []; 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' }); 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 <unsafe>' : '' }; }, exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
}); });
const boardURL = `http://127.0.0.1:${board.address().port}`; const web = await startServer({ port: 0, board });
const web = await startServer({ port: 0, board: boardURL }); return { root, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; }, async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
}; };
} }
+4 -5
View File
@@ -5,10 +5,9 @@
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.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(); 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 }); mkdirSync(sessionsDir, { recursive: true });
const seatsDir = join(root, 'seats'), captures = []; 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) })); 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: '' }; } }); 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, 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 }); },
}; };
} }
+1 -1
View File
@@ -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 // 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. // copy, and the queue read from --checkout or, without it, from the copy.
async function serve(t, cli, args, env) { 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()); t.after(() => child.kill());
let out = '', err = ''; let out = '', err = '';
child.stderr.on('data', (c) => { err += c; }); child.stderr.on('data', (c) => { err += c; });
+1 -1
View File
@@ -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 => { 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. // 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 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()); t.after(() => child.kill());
let out = ''; let out = '';
const port = await new Promise((resolve, reject) => { const port = await new Promise((resolve, reject) => {
+3 -3
View File
@@ -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();`); 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}` } }); cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
for (const c of cases) { 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.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)); assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
if (c.value) assert.ok(!r.stderr.includes(c.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 => { test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
const f = fixture(t); const f = fixture(t);
for (const on of [true, false]) { 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); 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'); } }); t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => { 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'); const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 }); writeFileSync(file, VALUE, { mode: 0o600 });
await new Promise(r=>f.web.close(r)); 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(); 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'); } }); t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
+4 -5
View File
@@ -1,7 +1,7 @@
// Row 178: fixture proof values only. No installed proof, proxy or unit is read. // Row 178: fixture proof values only. No installed proof, proxy or unit is read.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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 { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -24,9 +24,8 @@ async function setup(t) {
writeFileSync(file, VALUE + '\n', { mode: 0o600 }); writeFileSync(file, VALUE + '\n', { mode: 0o600 });
const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview); const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
const calls = [], logs = []; const calls = [], logs = [];
const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); }); const board = { index: async () => { calls.push('board'); return { 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,
const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) }); 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)); t.after(() => close(server));
return { port: server.address().port, calls, logs, proof, file }; 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); 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-')); const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
t.after(()=>rmSync(testerRoot,{recursive:true,force:true})); 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)); t.after(() => close(server));
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'}); const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
assert.equal(result.status, 403); assert.deepEqual(f.calls, []); assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
+1 -1
View File
@@ -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.instances.pm.provider, 'zai');
assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2); assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2);
assert.match(body.launch.revision, /^[a-f0-9]{64}$/); 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); clean(r.text, s.base, repo.base);
// No notifier config: still 200, so appearance works; the reason is redacted. // No notifier config: still 200, so appearance works; the reason is redacted.
const bare = seeded(t, { notify: false }); const bare = seeded(t, { notify: false });
+7 -9
View File
@@ -3,18 +3,17 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { browser } from './browser.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 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`; 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 () => { 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-')); const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
let board, web, b; let web, b;
try { try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir); const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl'); const file = join(sessionsDir, 's.jsonl');
@@ -23,16 +22,15 @@ test('both presentations replace old activity with relaunch notice, label retain
writeFileSync(file, history); writeFileSync(file, history);
const seatsDir = join(root, 'seats'); 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' }); 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 board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true, exec: () => { throw Error('no transport expected'); } });
const boardURL = `http://127.0.0.1:${board.address().port}`; web = await startServer({ port: 0, board });
web = await startServer({ port: 0, board: boardURL });
const webURL = `http://127.0.0.1:${web.address().port}`; const webURL = `http://127.0.0.1:${web.address().port}`;
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0]; const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt); 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'); 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); 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')})()`); 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'); const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false); assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector(".row-toggle").click()'); 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")'); 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('relaunched at'), false);
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), 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); 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 }); }
}); });
+5 -6
View File
@@ -10,11 +10,10 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { browser } from './browser.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 at = s => new Date(Date.now() + s * 1000).toISOString();
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n'; 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 () => { 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-')); const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
let board, web, b; let web, b;
try { try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir); const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl'); 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')); 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 = []; 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) })); 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: '' }; } }); 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); b = await browser(); await b.viewport(1440, 1000);
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`); const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent'); 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(); const later = await inspection();
assert.doesNotMatch(later, /Waiting for a reply|before your message/); assert.doesNotMatch(later, /Waiting for a reply|before your message/);
assert.match(later, /Last assistant text/); 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 }); }
}); });
+2 -2
View File
@@ -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 reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser(); 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 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 text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? 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 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 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 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})()`); 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})()`);
+1 -1
View File
@@ -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. // 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 root = tmp(t, 'webui-send-cli-');
const { env } = fixture(root); 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()); t.after(() => child.kill());
let out = ''; let out = '';
const port = await new Promise((resolve, reject) => { const port = await new Promise((resolve, reject) => {
+29 -50
View File
@@ -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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createServer, get } from 'node:http'; import { get } from 'node:http';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; 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'; 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) }); 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 ['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', '']) { for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
assert.equal(isLoopback(h), false); assert.equal(isLoopback(h), false);
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/); 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:[email protected]', '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 () => { 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(); } } finally { await f.close(); }
}); });
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => { test('in-process board routes: writes refused without --send, reads pass the board result through, a failing read answers an error', async () => {
const requests = []; const f = await fixture();
const upstream = createServer(async (req, res) => { const web = await startServer({ port: 0, board: f.board });
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 <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
});
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}` });
const base = `http://127.0.0.1:${web.address().port}`; const base = `http://127.0.0.1:${web.address().port}`;
try { try {
for (const path of ['/api/reply', '/api/seen']) { // Without --send both writes refuse before the board runs.
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }'; for (const path of ['/api/seen', '/api/reply']) {
const res = await post(base, path, raw); const res = await post(base, path, { agent: 'proj/agent1', text: 'blocked', seen: true });
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}'); assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
} }
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) { assert.equal(f.captures.length, 0, 'the board never ran a write');
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; } assert.equal((await fetch(base + '/api/reply')).status, 405);
} assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400); // Reads: the board's own status and body come back as the board sent them.
assert.equal(requests.length, 2); for (const path of ['/api/conversations', '/api/conversation?id=x']) {
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']) {
const res = await fetch(base + path); const res = await fetch(base + path);
assert.equal(res.status, 404, path); const want = await f.board.conversation(new URL(path, 'http://localhost'));
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); assert.equal(res.status, want.status, path);
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' }); assert.deepEqual(await res.json(), want.body, path);
} }
const before = requests.length; // A failing board read is an error answer, not a crash.
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405); f.board.setDown(true);
assert.equal((await post(base, '/api/conversations', '{}')).status, 405); assert.ok((await fetch(base + '/api/board')).status >= 400);
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403); } finally { await close(web); await f.close(); }
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); }
}); });
test('unreachable board reports URL; CLI rejects unsupported options', async () => { test('no board configured: the board routes answer 503; CLI rejects retired --board and other bad options', async () => {
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`; const web = await startServer({ port: 0, board: null });
const web = await startServer({ port: 0, board: url, timeout: 100 });
try { try {
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`); const base = `http://127.0.0.1:${web.address().port}`;
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url)); assert.equal((await fetch(base + '/api/board')).status, 503);
assert.equal((await fetch(base + '/api/conversations')).status, 503);
} finally { await close(web); } } finally { await close(web); }
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) { 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' }); 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:/); assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
} }
}); });
+6 -5
View File
@@ -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) => { test('render carries every serve flag, the preview set included', async (t) => {
const dir = tmp(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(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) => { 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; } if (i >= 0) { preview[i + 1] = value; return preview; }
return [flag, value]; 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', '"', '\\', '|', '&']) { 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)]); const r = await run(dir, ['render', ...set(flag, value)]);
assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`); assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`);
assert.equal(r.stdout, '', `${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]); const r = await run(dir, ['render', '--port', port]);
assert.equal(r.status, 1, `port ${JSON.stringify(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')]) { 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]); const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 1, args.join(' ')); 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'], [['--port', '7340'], '--port 7340'],
[['--business', 'mosaic'], '--port 7332 --business mosaic'], [['--business', 'mosaic'], '--port 7332 --business mosaic'],
[PREVIEW, `--port 7332 ${PREVIEW_FLAGS}`], [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]); const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
+1 -1
View File
@@ -42,7 +42,7 @@ async function setup() {
const reader = b0.bindReader({ business: 'demo' }); const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine: true }); 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.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(1280); 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)})})()`); 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)})})()`);
+1 -1
View File
@@ -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'); const hook = join(f.root, 'spawn-hook.mjs');
writeFileSync(log, ''); 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();`); 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 = ''; let output = '', error = '';
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c); 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'); } }); t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
+2
View File
@@ -34,6 +34,8 @@ COPY_PATHS=(
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
packages/processing-control packages/processing-control
packages/webui/src packages/webui/README.md packages/cli/README.md 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 packages/queue/src packages/library/src packages/library/bin
# Whole directory: every in-repo plug-in module ships with the copy (row 131). # Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins packages/plugins
+2 -10
View File
@@ -42,7 +42,7 @@
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own # 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 # Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies), # (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 # 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 # (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 # human messages to roles and needs the system config at start. systemd
@@ -93,7 +93,7 @@ instance_select
UNIT_NAME="$CONSOLE_UNIT" UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}" 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] 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. --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." --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_SET=0
TESTER_SOCKET=0 TESTER_SOCKET=0
BUSINESS="" BUSINESS=""
BOARD=""
PUBLIC_HOST="" PUBLIC_HOST=""
AUTH_HEADER="" AUTH_HEADER=""
ALLOW_USER="" ALLOW_USER=""
@@ -136,7 +135,6 @@ parse_flags() {
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;; --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 ;; --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 ;; --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 ;; --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 ;; --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 ;; --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" [ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
flags="$flags --business $BUSINESS" flags="$flags --business $BUSINESS"
fi 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 local preview=0
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1)) [ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1)) [ -z "$AUTH_HEADER" ] || preview=$((preview + 1))