diff --git a/agents/researcher/work/queue-220/r2/BUILD.md b/agents/researcher/work/queue-220/r2/BUILD.md index 29ca0d9de..322c43fb8 100644 --- a/agents/researcher/work/queue-220/r2/BUILD.md +++ b/agents/researcher/work/queue-220/r2/BUILD.md @@ -14,7 +14,7 @@ committed. Phase B (the suites) waits for the coordinator's "slot clear". ## Coordinator rulings, applied Round 1 answers (earlier packet): applied as before. Absent `businesses/` gives empty sets only on -ENOENT; other failures throw. stack-copy test joins phase B. Webui fixtures pass `businessDir`. Discord +ENOENT; other failures throw. stack-copy test joins phase B. Webui fixtures pass `businessDir` (renamed `configDirectory` in round 2b below). Discord refusals exit 2. Round 2 rulings (coordinator message, this round): @@ -213,3 +213,100 @@ Every node file runs as its own `setsid -w timeout -k 10 LIMIT node --test FILE` | test-extension-package | 130 s | test-foundation | 240 s | Any timeout goes to Sage, then Mos, unplaced (mos-1275). + +## Round 2b: Filbert's early look and sage-2719 (2026-10-22T14:46Z) + +Added after the text above; nothing above is edited. Where earlier text says `businessDir`, the parameter is +now `configDirectory` (below). Second commit on `researcher/row220-r2`, on top of ea6dc9eff. Not pushed, not +run; `node --check` has not been run on any of these files (no node before the CLEAR). + +Rulings applied (sage-2719): Q1 keep the index export. C1, C2 and the fixture scope are changes. + +### Fixture scope: the parameter is a config directory + +`createServer` took `businessDir`, but `creativeCheck(dir)` reads `/businesses`, so every fixture that +passed `join(root, 'businesses')` made the reader look in `/businesses/businesses`. That read nothing +(ENOENT, empty sets): isolation from the real config held by accident. Now: + +- `createServer({ configDirectory = configDir() })` in `serve.mjs`. The parameter is not named `configDir` + because the import of that name is the default's source. +- Every fixture passes the config directory it owns: `configDirectory: root` in the five webui files, + `f.root` in `control-board/tests/discord.test.mjs`, and `f.configDirectory` in `serve.test.mjs`. + `conversationFixture()` and `replyFixture()` both return `configDirectory: root`. The seven reply starts + that passed `f.businessDir` (undefined, falling back to the real `configDir()`) now pass a real value. +- The dead `mkdirSync(join(root, 'businesses'))` lines in the five webui fixtures and in `serve.test.mjs` + are removed. So is the one in `discord/tests/helpers.mjs` `makeDeployment`, whose comment said "a missing + one refuses"; that was false (lstat ENOENT gives empty sets). The comment now says nothing there means no + library seat. Tests that need a `businesses/` directory create it: `writeJson` does, and the two + unreadable-file tests in `library-seat.test.mjs` call `mkdirSync` themselves. +- Proof that the fixture's own business file is the one read: the new default-reader test (C2) hides `bean` + and shows `pm`, and nothing else in that fixture says which is which. + +### C1: M8 on `run` + +`library-seat.test.mjs` gains three `run` tests: a raw engine, a mosaic-bus binding with `tools`, and an +unreadable business file. Each asserts exit 2 and the code in stderr, and that `s.d.journalDir` does not +exist (`ensureJournal` comes after `prepare()` in `run`, so nothing was started). `run()` calls +`initializeControl(dataRoot)` first, because `run` stops at `assertProcessing` before `prepare()`, as the +STOP test in `binding.test.mjs:203` does. There is no `run` test for a clean seat: that would go on to the +network. + +### C2: the default reader + +`creative-hidden.test.mjs`, "the board's default reader hides a creative seat's session from the listing and +the detail route". It calls `startServer` with no `conversationReader`, `configDirectory: f.dir`, a `bean` +session and a `pm` session on disk, and drives HTTP: the listing holds `pm` only; bean's detail is 404 +`conversation-not-listed`; pm's detail is 200; after a broken business file lands the listing is 503 +`conversation-business-unreadable`. + +### Mutants to throw (CLEAR only; none thrown; the kill claims are unverified) + +| Id | Mutation | Meant to be killed by | +|---|---|---| +| M7-w | delete `creativeCheck: creativeCheck(configDirectory),` in `createServer` (`serve.mjs`) | the default-reader test, first assertion (the listing shows `bean`) | +| M7-c | `hiddenCheck()` in `reader.mjs` open() returns `() => false` | reader.test "hides the creative seat" and the `creative-hidden` 404 asserts | +| M7-o | `creativeCheck` catch returns an empty set instead of throwing | the 503 asserts in `creative-hidden` | +| M8-p | delete `checkLibraryEngine(...)` in `prepare()` | the three `check` refusals and the three `run` refusals; one mutant covers both verbs because they share `prepare()` | +| M8-e | `loadSeatSets` returns empty sets on any lstat error | `library-seats` ENOENT-vs-file tests | + +### `refusalResponse` and `catalogue()`'s refusal (past "predicate wiring and REFUSAL_STATUS only") + +Why it is needed: the reader's `catalogue()` now returns a refusal when the business check throws (M7: no +partial list), and `conversationResponse` had no way to turn a catalogue refusal into a status. `open` and +`next` already built `{ status: REFUSAL_STATUS[code] ?? 422, body: { error, refusal: { code, reconcile } } }` +inline; that expression moved, character for character, into `refusalResponse(out)` so the listing could +use it too. + +No existing route's status or body changes: + +- `open` and `next` refusals: the same expression, now behind a function. `git diff aca16404c..HEAD -- + packages/control-board/src/serve.mjs` shows the line as a move. +- `/api/conversations` success: `{ status: 200, body: listing }` where `listing` is what the route returned + before. `catalogue()` returned `ok: true` on every path before this row, so the new branch is reached only + by the business refusal. +- The "no parameters are accepted" 400 is untouched. + +Phase B runs `serve.test.mjs`, `discord.test.mjs` and the conversation route tests whole, so Filbert can +check this statement against a green run. + +### Smaller changes from the early look + +- Spy test: one `next()` call adds a third `creativeCheck` count (`reader.test.mjs`). The catalogue assert + also checks `refusedRoots` is empty, in the same test and in the thread-store test in `creative-hidden`. +- `library-seats.test.mjs` mode-000 test pins `BusinessError` and its message (`must be a regular, + non-symbolic-link file`, from `readJsonFile`'s open failure). +- "passes the engine rule" now asserts exit 0 and `offline check passed`. The expectation is unverified: if + a mosaic-bus binding fails some other offline check, that is a first-run red to place (mos-1272). + +### Carried, not changed here + +- `conversation/src/controller.mjs:176` builds a reader with no `creativeCheck`. No `src` file imports it + today; a live wiring must pass the check. Raised for Sage. +- `discord run` reads the business file once at start. `engine` and `tools` are FIXED_KEYS, so only a + business-file change while a connector runs goes unseen until the next start. +- `creative-hidden` and `library-seats` use the repo `roles/` as the default `rolesDir`. + +### Gate additions + +Phase B list is unchanged: the same suites run whole, plus the files above are inside them. The drift check +against `aca16404c..refactor`, including files these tests copy or read, repeats after the rebase onto 253. diff --git a/packages/business/tests/library-seats.test.mjs b/packages/business/tests/library-seats.test.mjs index de8d44057..739f59fa4 100644 --- a/packages/business/tests/library-seats.test.mjs +++ b/packages/business/tests/library-seats.test.mjs @@ -88,5 +88,5 @@ test("M7/M8: a business file that can't be read throws; no empty set", (t) => { t.after(() => chmodSync(file, 0o600)); // Root reads a mode 000 file, so the check cannot hold there. if (process.getuid?.() === 0) return t.skip("running as root: mode 000 files stay readable"); - assert.throws(() => sets(dir)); + assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /must be a regular, non-symbolic-link file/.test(err.message)); }); diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 2fcf662ac..33035eff4 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -253,12 +253,13 @@ 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, businessDir = configDir() }) { +// specs: agent specs to scan on each request. boardDir: where scan writes. configDirectory: the config +// directory whose businesses/ the default conversation reader reads for M7 (the instance's, by default). +export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null, configDirectory = configDir() }) { const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations), - creativeCheck: creativeCheck(businessDir), + creativeCheck: creativeCheck(configDirectory), }); return createHttpServer((req, res) => { const refused = foreignRequest(req); diff --git a/packages/control-board/tests/creative-hidden.test.mjs b/packages/control-board/tests/creative-hidden.test.mjs index aa66131b2..c2abd348e 100644 --- a/packages/control-board/tests/creative-hidden.test.mjs +++ b/packages/control-board/tests/creative-hidden.test.mjs @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { conversationResponse, creativeCheck, REFUSAL_STATUS } from "../src/serve.mjs"; +import { conversationResponse, creativeCheck, startServer, REFUSAL_STATUS } from "../src/serve.mjs"; import { conversationId, createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; import { businessDoc, writeJson } from "../../business/tests/helpers.mjs"; import { mappingEntry } from "../../library/tests/helpers.mjs"; @@ -52,7 +52,8 @@ test("M7: a thread store is not a root from a spec, and a root built over it is // The spec layout check in rootsFromSpecs drops a thread-store directory. assert.deepEqual(rootsFromSpecs([{ agent: "bean", project: "acme", sessionsDir: f.threadDir }]), []); const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); - assert.deepEqual(reader.catalogue().conversations, []); + const cat = reader.catalogue(); + assert.deepEqual([cat.conversations, cat.refusedRoots], [[], []], "the root is hidden, not refused"); const out = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); assert.equal(out.status, REFUSAL_STATUS["conversation-not-listed"]); assert.equal(out.status, 404); @@ -83,3 +84,47 @@ test("M7: an unreadable business file refuses the listing and open with 503, nev assert.equal(opened.status, REFUSAL_STATUS["conversation-business-unreadable"]); assert.equal(opened.status, 503); }); + +// C2: every test above builds its own reader. This one starts the board with no +// injected reader, so the check reaches the reader only through createServer's +// wiring (serve.mjs: `creativeCheck: creativeCheck(configDirectory)`). Deleting that +// line shows bean's session in the listing and fails the first assertion below. +// It also shows the business file in the fixture's config directory is the one +// read: bean is hidden and pm is not, and no other file says so. +test("M7: the board's default reader hides a creative seat's session from the listing and the detail route", async (t) => { + const f = fixture(t); + const proj = join(f.root, "proj"); + const specs = []; + for (const [n, seat] of ["bean", "pm"].entries()) { + const sessionsDir = join(proj, ".pi", "state", seat, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const header = { type: "session", version: 3, id: `c0ffee00-0000-4000-8000-00000000000${n + 1}`, timestamp: "2026-10-22T12:00:00.000Z", cwd: proj }; + const message = { type: "message", id: "e0", parentId: null, timestamp: "2026-10-22T12:00:01.000Z", message: { role: "user", content: [{ type: "text", text: `${seat.toUpperCase()}-TEXT` }] } }; + writeFileSync(join(sessionsDir, "s.jsonl"), [header, message].map((l) => JSON.stringify(l)).join("\n") + "\n"); + specs.push({ agent: seat, project: "proj", sessionsDir, tmux: {} }); + } + const roots = rootsFromSpecs(specs); + const idOf = (seat) => conversationId(roots.find((r) => r.seat === seat), "s.jsonl"); + const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(f.root, "board"), seatsDir: join(f.root, "seats"), configDirectory: f.dir, isAlive: () => true, page: "" }); + t.after(() => { server.closeAllConnections(); server.close(); }); + const base = `http://127.0.0.1:${server.address().port}`; + const get = async (path) => { + const res = await fetch(base + path); + return { status: res.status, text: await res.text() }; + }; + const cat = await get("/api/conversations"); + assert.equal(cat.status, 200, cat.text); + assert.deepEqual(JSON.parse(cat.text).conversations.map((c) => c.seat), ["pm"]); + assert.ok(!cat.text.includes("BEAN-TEXT")); + const hidden = await get(`/api/conversation?id=${encodeURIComponent(idOf("bean"))}`); + assert.equal(hidden.status, 404, hidden.text); + assert.equal(JSON.parse(hidden.text).refusal.code, "conversation-not-listed"); + assert.ok(!hidden.text.includes("BEAN-TEXT")); + const shown = await get(`/api/conversation?id=${encodeURIComponent(idOf("pm"))}`); + assert.equal(shown.status, 200, shown.text); + // The file is read on every call: a broken business file now refuses the same route. + breakBusinessFile(f.dir); + const refused = await get("/api/conversations"); + assert.equal(refused.status, 503, refused.text); + assert.equal(JSON.parse(refused.text).refusal.code, "conversation-business-unreadable"); +}); diff --git a/packages/control-board/tests/discord.test.mjs b/packages/control-board/tests/discord.test.mjs index e50a0ec96..3b37218fb 100644 --- a/packages/control-board/tests/discord.test.mjs +++ b/packages/control-board/tests/discord.test.mjs @@ -120,7 +120,7 @@ test('server rescans connector discovery and refuses HTTP reply without transpor try { const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats'); writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid })); - server = await startServer({ port: 0, specs: [], seatsDir, businessDir: join(f.root, 'businesses'), boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); + server = await startServer({ port: 0, specs: [], seatsDir, configDirectory: f.root, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); const base = `http://127.0.0.1:${server.address().port}`; let response = await fetch(base + '/api/board'); const board = await response.json(); assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true); diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index b5f5c49cd..2c2d11ba2 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -725,7 +725,7 @@ function replyFixture({ tmux = { socket: null, session: "agent1" }, pid = proces } const capture = join(root, "capture.json"); process.env.FAKE_SEND_CAPTURE = capture; - return { root, sessionsDir, seatsDir, boardDir: join(root, "board"), agentSend: writeFakeAgentSend(root), capture, + return { root, sessionsDir, seatsDir, configDirectory: root, boardDir: join(root, "board"), agentSend: writeFakeAgentSend(root), capture, specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] }; } @@ -736,7 +736,7 @@ async function postReply(base, body, headers = { "content-type": "application/js test("POST /api/reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z") }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z") }); const base = `http://127.0.0.1:${server.address().port}`; process.env.MOSAIC_TMUX_SOCKET = "leaked-from-launcher"; delete process.env.FAKE_SEND_EXIT; @@ -768,7 +768,7 @@ test("POST /api/reply: runs agent-send.sh with -s from the registration, -S ", async () => { const f = replyFixture({ tmux: { socket: "mosaic-fleet", session: "agent1" } }); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: f.agentSend }); const base = `http://127.0.0.1:${server.address().port}`; try { const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" }); @@ -783,7 +783,7 @@ test("POST /api/reply: a registration with a tmux socket adds -L ", asyn test("POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: f.agentSend }); const base = `http://127.0.0.1:${server.address().port}`; process.env.FAKE_SEND_EXIT = "4"; process.env.FAKE_SEND_STDERR = "agent-send.sh: REFUSING - session 'agent1' exists on multiple sockets\n"; @@ -814,7 +814,7 @@ test("POST /api/reply: refusals before the tool runs: empty or blank or long tex ]; for (const c of cases) { const f = c.fixture; - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend, isPidAlive: c.isPidAlive }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: f.agentSend, isPidAlive: c.isPidAlive }); const base = `http://127.0.0.1:${server.address().port}`; try { const { status, body } = await postReply(base, c.body); @@ -829,7 +829,7 @@ test("POST /api/reply: refusals before the tool runs: empty or blank or long tex test("POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: join(f.root, "missing-agent-send.sh") }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: join(f.root, "missing-agent-send.sh") }); const base = `http://127.0.0.1:${server.address().port}`; try { const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" }); @@ -964,7 +964,7 @@ function rawRequest(port, { method = "GET", path = "/api/board", headers = {}, b test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: f.agentSend }); const port = server.address().port; const own = `127.0.0.1:${port}`; const reply = JSON.stringify({ agent: "proj/agent1", text: "rebound page" }); @@ -1003,7 +1003,7 @@ test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Hos test("Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", agentSend: f.agentSend }); const port = server.address().port; delete process.env.FAKE_SEND_EXIT; delete process.env.FAKE_SEND_STDERR; @@ -1049,9 +1049,8 @@ function conversationFixture() { resolved: { seat: "rocko", project: "proj", sessionsDir: join(proj, ".pi", "state", "rocko", "sessions"), seatDir: join(root, "rocko"), launchScript: join(root, "rocko", "launch.sh"), layout: "repo", defaultWorkspace: null }, harness: "claude-code", })); - // Row 220 M7: the default reader reads the businesses beside this fixture, never the real config. - mkdirSync(join(root, "businesses"), { recursive: true }); - return { root, proj, sessionsDir, seatsDir, businessDir: join(root, "businesses"), boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] }; + // Row 220 M7: the default reader reads /businesses, which does not exist here, never the real config. + return { root, proj, sessionsDir, seatsDir, configDirectory: root, boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] }; } // Size, sha256, mtime, (dev, ino) and listing of every entry under dir. @@ -1072,7 +1071,7 @@ test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin const f = conversationFixture(); const calls = []; const spy = { catalogue: () => calls.push("catalogue"), open: () => calls.push("open"), next: () => calls.push("next") }; - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", conversationReader: spy }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "", conversationReader: spy }); const port = server.address().port; const own = `127.0.0.1:${port}`; const cases = [ @@ -1121,7 +1120,7 @@ test("every refusal code the reader can raise has an HTTP status", () => { test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => { const f = conversationFixture(); const before = treePrint(f.sessionsDir); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "" }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "" }); const base = `http://127.0.0.1:${server.address().port}`; const get = async (path) => { const res = await fetch(base + path); diff --git a/packages/conversation/tests/reader.test.mjs b/packages/conversation/tests/reader.test.mjs index cbd0497ad..e6aaa6a58 100644 --- a/packages/conversation/tests/reader.test.mjs +++ b/packages/conversation/tests/reader.test.mjs @@ -725,6 +725,9 @@ test("M7: one catalogue calls the creative check once, however many roots it lis const shown = noWrites(tmp, () => reader.open({ conversation: conversationId(roots.find((r) => r.seat === "sage"), "s.jsonl") })); assert.equal(shown.ok, true); assert.equal(calls, 2, "open is one more public call, so one more check"); + noWrites(tmp, () => reader.next({ cursor: shown.follow.id, conversation: conversationId(roots.find((r) => r.seat === "sage"), "s.jsonl"), branch: shown.page.branch })); + assert.equal(calls, 3, "next is one more public call, so one more check"); + assert.deepEqual(cat.refusedRoots, [], "a hidden root is not a refused root"); }); test("M7: a creative session already open refuses its next page once the root is hidden", () => { diff --git a/packages/discord/tests/helpers.mjs b/packages/discord/tests/helpers.mjs index b7dafeb38..291bb1b28 100644 --- a/packages/discord/tests/helpers.mjs +++ b/packages/discord/tests/helpers.mjs @@ -77,9 +77,9 @@ export function makeDeployment(root, overrides = {}, { tokenMode = 0o600, tokenC writeFileSync(bindingFile, JSON.stringify(raw, null, 2), { mode: 0o600 }); const config = join(root, "config.json"); writeFileSync(config, JSON.stringify({ dataRoot })); - // Row 220 M8: check reads the businesses directory beside --config. An empty - // one means no library seats; a missing one refuses. - mkdirSync(join(root, "businesses"), { recursive: true, mode: 0o700 }); + // Row 220 M8: check and run read /businesses beside --config. Nothing + // there means no library seat, so a deployment starts with none; the tests + // that need one write a business file into it. return { dataRoot, config, bindingFile, tokenFile, raw, journalDir: join(dataRoot, "discord", raw.name) }; } diff --git a/packages/discord/tests/library-seat.test.mjs b/packages/discord/tests/library-seat.test.mjs index 1fb83f43f..315f69698 100644 --- a/packages/discord/tests/library-seat.test.mjs +++ b/packages/discord/tests/library-seat.test.mjs @@ -1,14 +1,16 @@ // Case 2 M8 (design 5.1, row 220): a binding for a seat in a library business // runs only on the mosaic-bus engine with no tools key. The business files sit // in the directory beside --config. An unreadable file refuses with -// discord-business-unreadable. Checks run with --offline: no network. +// discord-business-unreadable. Both verbs refuse: check runs with --offline, and +// run refuses in prepare(), before the journal is made and before any network. import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmodSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { makeRoot, makeRepo, makeDeployment } from "./helpers.mjs"; +import { initializeControl } from "../../processing-control/src/state.mjs"; import { businessDoc, writeJson } from "../../business/tests/helpers.mjs"; import { mappingEntry } from "../../library/tests/helpers.mjs"; @@ -33,6 +35,12 @@ function business(s, { library, role = "sage", definition = "companion" }) { writeJson(join(s.root, "businesses", "acme.json"), doc); } +// run stops at the processing gate first, so the deployment's control state is initialised. +function run(s) { + initializeControl(s.d.dataRoot); + return spawnSync(process.execPath, [CLI, "run", s.d.raw.name, "--config", s.d.config, "--repo", s.repo], { encoding: "utf8", timeout: 10000 }); +} + function check(s) { return spawnSync(process.execPath, [CLI, "check", s.d.raw.name, "--config", s.d.config, "--repo", s.repo, "--offline"], { encoding: "utf8", timeout: 10000 }); } @@ -58,10 +66,13 @@ test("M8: a library seat's mosaic-bus binding with no tools passes the engine ru business(s, { library: true }); const r = check(s); assert.doesNotMatch(r.stderr, /discord-engine-not-allowed|discord-business-unreadable/); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /offline check passed/); }); test("M8: an unreadable business file refuses check with discord-business-unreadable", () => { const s = setup(); + mkdirSync(join(s.root, "businesses")); writeFileSync(join(s.root, "businesses", "broken.json"), "{"); chmodSync(join(s.root, "businesses", "broken.json"), 0o600); const r = check(s); @@ -76,3 +87,32 @@ test("M8: a binding on a seat that is in no library business checks clean, whate assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /offline check passed/); }); + +test("M8: run refuses a library seat's raw engine with exit 2, before the journal is made and before any network", () => { + const s = setup(); + business(s, { library: true }); + const r = run(s); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /discord-engine-not-allowed: .*its engine must be mosaic-bus, not zai/); + assert.equal(existsSync(s.d.journalDir), false, "nothing was started"); +}); + +test("M8: run refuses a library seat's mosaic-bus binding that declares tools", () => { + const s = setup((root) => ({ engine: MOSAIC_BUS, tools: { roots: [{ name: "docs", path: join(root, "docs") }] } })); + business(s, { library: true }); + const r = run(s); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /discord-engine-not-allowed: .*tools key/); + assert.equal(existsSync(s.d.journalDir), false); +}); + +test("M8: an unreadable business file refuses run with discord-business-unreadable, before the journal is made", () => { + const s = setup(); + mkdirSync(join(s.root, "businesses")); + writeFileSync(join(s.root, "businesses", "broken.json"), "{"); + chmodSync(join(s.root, "businesses", "broken.json"), 0o600); + const r = run(s); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /discord-business-unreadable/); + assert.equal(existsSync(s.d.journalDir), false); +}); diff --git a/packages/webui/tests/discord.test.mjs b/packages/webui/tests/discord.test.mjs index 069f870c4..0a9c0c2df 100644 --- a/packages/webui/tests/discord.test.mjs +++ b/packages/webui/tests/discord.test.mjs @@ -23,8 +23,7 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply // #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing. const seatsDir = join(root, 'seats'); writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' }); - mkdirSync(join(root, 'businesses')); - board = await startBoard({ port: 0, specs: [], seatsDir, businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } }); + board = await startBoard({ port: 0, specs: [], seatsDir, configDirectory: root, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } }); web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); const base = `http://127.0.0.1:${web.address().port}`; const payload = await (await fetch(base + '/api/board')).json(); diff --git a/packages/webui/tests/fixture.mjs b/packages/webui/tests/fixture.mjs index 6dc1d9528..a26c86c61 100644 --- a/packages/webui/tests/fixture.mjs +++ b/packages/webui/tests/fixture.mjs @@ -29,8 +29,7 @@ export async function fixture() { updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' }); } } - mkdirSync(join(root, 'businesses')); - const board = await startBoard({ port: 0, specs, seatsDir, businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true, + const board = await startBoard({ port: 0, specs, seatsDir, configDirectory: root, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true, exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal ' : '' }; }, }); const boardURL = `http://127.0.0.1:${board.address().port}`; diff --git a/packages/webui/tests/history-fixture.mjs b/packages/webui/tests/history-fixture.mjs index 07852c0ad..43a46ed96 100644 --- a/packages/webui/tests/history-fixture.mjs +++ b/packages/webui/tests/history-fixture.mjs @@ -36,8 +36,7 @@ export async function historyFixture({ seat = 'fixture', project = 'repo' } = {} mkdirSync(sessionsDir, { recursive: true }); const seatsDir = join(root, 'seats'), captures = []; writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) })); - mkdirSync(join(root, 'businesses')); - const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, + const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], configDirectory: root, boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } }); const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, diff --git a/packages/webui/tests/relaunch.test.mjs b/packages/webui/tests/relaunch.test.mjs index 31bc2316e..588744f1c 100644 --- a/packages/webui/tests/relaunch.test.mjs +++ b/packages/webui/tests/relaunch.test.mjs @@ -23,8 +23,7 @@ test('both presentations replace old activity with relaunch notice, label retain writeFileSync(file, history); const seatsDir = join(root, 'seats'); writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' }); - mkdirSync(join(root, 'businesses')); - board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } }); + board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], configDirectory: root, boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } }); const boardURL = `http://127.0.0.1:${board.address().port}`; web = await startServer({ port: 0, board: boardURL }); const webURL = `http://127.0.0.1:${web.address().port}`; diff --git a/packages/webui/tests/return-flow.test.mjs b/packages/webui/tests/return-flow.test.mjs index 99ec95d77..522bd5127 100644 --- a/packages/webui/tests/return-flow.test.mjs +++ b/packages/webui/tests/return-flow.test.mjs @@ -29,8 +29,7 @@ test('reported return flow and relative Age: reply sent from the inspector, then writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER')); const seatsDir = join(root, 'seats'), captures = []; writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) })); - mkdirSync(join(root, 'businesses')); - board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, + board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], configDirectory: root, boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } }); web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); b = await browser(); await b.viewport(1440, 1000);