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);