agents: Rocko row 164 r2 packet (#1661)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 14:24:42 -05:00
co-authored by Claude Opus 5.5
parent 008746b6ed
commit cfde62f9ff
20 changed files with 1298 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# Row 164 round 2
Addresses Dewey B1, #1661 comment 28980 (rev 1814), and sage-0846.
Base remains 850484a7d2cfc143b5f5413b491cd827b4a4fd14. The candidate differs from r1 only by one assertion in packages/cli/tests/channel-cli.test.mjs: the off/absent/other-only loop now requires `Pass --business "demo" to both commands.` for every operation. No product code changes. All three approved parity fixture hunks are byte-identical to r1, including the Mos mos-0789 line-47 exception.
N6 remains a non-blocking follow-up: no existing channel-config scenario in this file allows a one-line exit-code assertion.
## Verification
Sage approved the narrow plan in sage-0848 and granted the slot in sage-0850. Busy check CLEAR; only pid 2175820 was authorized to be ignored (no match occurred). Run span 2026-10-18T19:03:30Z to 19:03:49Z. channel-cli.test.mjs: 2 tests, 2 passed, 0 failed/cancelled/skipped, parsed from the runner summary. D1 was killed by the new assertion, with the failing assertion printed in its log. No suites, Docker, browser, live paths or units. Scratch TMPDIR=/mnt/storage/scratch/rocko-164/tmp; DOCKER_HOST=unix:///nonexistent.sock. Mutant runner uses start_new_session, a 180-second timeout, process-group termination and finally restores bytes. All 21 staged/worktree blobs match the manifest and reconstruct from base plus patch; cwd process sweep empty. Slot returned at 19:03:49Z.
The 50 prior mutant results and package counts (plugins 77, CLI 247, bus 369, WebUI 182; 875/875) are historical r1 evidence, not round-2 reruns. See ../r1/BUILD.md and ../r1/mutants.txt. The complete table below carries those 50 as r1-only evidence and adds D1 as round-2 evidence. No prior mutant or suite is claimed as a round-2 rerun.
## Cut-over
Unchanged: in Mos's re-pin window, Jason enables both channels with `mosaic plugins set` right after activate, then smokes CLI thread list and Console chat. No agent runs these steps. See ../r1/BUILD.md for the complete accepted cut-over and proof baselines.
## Mutant table
| Check | Killing test | Evidence | Result |
| --- | --- | --- | --- |
| 01-kind-version | packages/plugins/tests/channel.test.mjs — channel registry requires | r1 only | KILLED |
| 02-kind-switch | packages/plugins/tests/channel.test.mjs — channel registry requires | r1 only | KILLED |
| 03-cli-forward-verb | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 04-webui-forward-verb | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 05-cli-forward-args | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 06-webui-forward-args | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 07-cli-filter | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 08-webui-filter | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 09-cli-result | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 10-webui-refusal | packages/plugins/tests/channel.test.mjs — channel shipped modules load | r1 only | KILLED |
| 11-bad-name | packages/plugins/tests/channel.test.mjs — channel guards refuse bad names | r1 only | KILLED |
| 12-directory-check | packages/plugins/tests/channel.test.mjs — channel guards refuse symlinked module, index and kind directories | r1 only | KILLED |
| 13-index-check | packages/plugins/tests/channel.test.mjs — channel guards refuse bad names | r1 only | KILLED |
| 14-symlink-check | packages/plugins/tests/channel.test.mjs — channel guards refuse symlinked | r1 only | KILLED |
| 15-realpath-check | packages/plugins/tests/channel.test.mjs — channel last realpath check | r1 only | KILLED |
| 16-export-check | packages/plugins/tests/channel.test.mjs — channel guards refuse bad names | r1 only | KILLED |
| 17-function-check | packages/plugins/tests/channel.test.mjs — channel guards refuse bad names | r1 only | KILLED |
| 18-version-check | packages/plugins/tests/channel.test.mjs — channel guards refuse bad names | r1 only | KILLED |
| 19-snapshot-pair | packages/plugins/tests/channel.test.mjs — channel snapshot is | r1 only | KILLED |
| 20-snapshot-array-freeze | packages/plugins/tests/channel.test.mjs — channel snapshot is | r1 only | KILLED |
| 21-snapshot-entry-freeze | packages/plugins/tests/channel.test.mjs — channel snapshot is | r1 only | KILLED |
| 22-snapshot-unknown | packages/plugins/tests/channel.test.mjs — channel snapshot is | r1 only | KILLED |
| 23-constructor-name | packages/plugins/tests/channel.test.mjs — channel caller refuses invalid core | r1 only | KILLED |
| 24-constructor-call | packages/plugins/tests/channel.test.mjs — channel caller refuses invalid core | r1 only | KILLED |
| 25-settings-verb | packages/plugins/tests/channel.test.mjs — channel caller re-reads settings | r1 only | KILLED |
| 26-settings-shape | packages/plugins/tests/channel.test.mjs — channel caller malformed settings | r1 only | KILLED |
| 27-kind-isolation | packages/plugins/tests/channel.test.mjs — channel caller malformed settings | r1 only | KILLED |
| 28-named-selection | packages/plugins/tests/channel.test.mjs — channel caller re-reads settings | r1 only | KILLED |
| 29-absent-is-off | packages/plugins/tests/channel.test.mjs — channel caller re-reads settings | r1 only | KILLED |
| 30-empty-is-off | packages/plugins/tests/channel.test.mjs — channel caller re-reads settings | r1 only | KILLED |
| 31-settings-reread | packages/plugins/tests/channel.test.mjs — channel caller re-reads settings | r1 only | KILLED |
| 32-last-good | packages/plugins/tests/channel.test.mjs — channel caller uses last good | r1 only | KILLED |
| 33-dispatch-bypass | packages/plugins/tests/channel.test.mjs — channel caller invokes the selected loaded module | r1 only | KILLED |
| 34-module-operation-filter | packages/plugins/tests/channel.test.mjs — channel caller keeps the selected module operation filter | r1 only | KILLED |
| 35-cli-project | packages/cli/tests/channel-cli.test.mjs — CLI off, absent | r1 only | KILLED |
| 36-cli-threads | packages/cli/tests/channel-cli.test.mjs — CLI off, absent | r1 only | KILLED |
| 37-cli-thread | packages/cli/tests/channel-cli.test.mjs — CLI off, absent | r1 only | KILLED |
| 38-cli-talk | packages/cli/tests/channel-cli.test.mjs — CLI off, absent | r1 only | KILLED |
| 39-core-recovery | packages/cli/tests/channel-recovery.test.mjs — both channels off recover | r1 only | KILLED |
| 40-cli-exit | packages/cli/tests/channel-cli.test.mjs — CLI off, absent | r1 only | KILLED |
| 41-webui-bypass | packages/webui/tests/channel-chat.test.mjs — WebUI channel off returns | r1 only | KILLED |
| 42-webui-error-envelope | packages/webui/tests/channel-chat.test.mjs — WebUI channel off returns | r1 only | KILLED |
| 43-webui-status | packages/webui/tests/channel-chat.test.mjs — WebUI channel off returns | r1 only | KILLED |
| 44-webui-reflection | packages/webui/tests/channel-chat.test.mjs — WebUI channel-disabled recovery text | r1 only | KILLED |
| 45-proof-admission | packages/bus/tests/channel-proof.test.mjs — channel recovery still requires | r1 only | KILLED |
| 46-proof-path | packages/bus/tests/channel-proof.test.mjs — channel recovery still requires | r1 only | KILLED |
| 47-proof-bytes | packages/bus/tests/channel-proof.test.mjs — channel conversion preserves the four | r1 only | KILLED |
| 48-dispatch-lock | packages/plugins/tests/channel.test.mjs — channel dispatch holds no selection lock | r1 only | KILLED |
| 49-cli-recovery-text | packages/cli/tests/channel-cli.test.mjs — CLI off, absent | r1 only | KILLED |
| 50-webui-recovery-text | packages/webui/tests/channel-chat.test.mjs — WebUI channel off returns | r1 only | KILLED |
| D1: delete CLI business-selector recovery sentence | packages/cli/tests/channel-cli.test.mjs — CLI off, absent and other-only channel selections refuse every project/thread flow before any effect | r2 mutants/D1-business-selector-recovery.log | KILLED |
+1
View File
@@ -0,0 +1 @@
850484a7d2cfc143b5f5413b491cd827b4a4fd14
+986
View File
@@ -0,0 +1,986 @@
diff --git a/docs/prd/mosaic-stack.md b/docs/prd/mosaic-stack.md
index 794a39bbb..cf6e9f774 100644
--- a/docs/prd/mosaic-stack.md
+++ b/docs/prd/mosaic-stack.md
@@ -819,9 +819,9 @@ only hard waits in his inbox.
in `packages/queue/src/review.mjs`, the ledger's repository paths,
and the `refactor` branch in `scripts/land-work.sh`.
`gitea.baseUrl` exists in `vars.mjs` and the scripts don't read it.
- - Channels: the human channel is `via: 'cli'` in the broker, the
- WebUI reaches the bus by running the human CLI, and Discord isn't
- connected to the bus.
+ - Channels: `cli` and `webui` are channel plug-in modules, selected by
+ the organization's `plugins.channel` list. Both retain the core human
+ proof transport (`via: 'cli'`); Discord isn't connected to the bus.
- Version 1.4:
- Jason approved REQ-WEB-3 in decision 021af0c8 (prd.approve, choice
yes; the approval message was request 2e031716, from request
diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs
new file mode 100644
index 000000000..a1e6ad3b2
--- /dev/null
+++ b/packages/bus/tests/channel-proof.test.mjs
@@ -0,0 +1,44 @@
+// Row 164 explicitly freezes proof bytes and its literal entry-path rule.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
+import { Client } from "../src/client.mjs";
+
+const baselines = {
+ "packages/bus/src/human.mjs": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573",
+ "packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
+ "packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
+ "packages/webui/src/bus.mjs": "dbad731ca8be19619064f050e766aeca856cf614783b9eca887caff3c2451f24",
+};
+const repoFile = path => new URL("../../../" + path, import.meta.url);
+
+test("channel conversion preserves the four proof/transport byte baselines and modules cannot import a proof caller", () => {
+ for (const [file, hash] of Object.entries(baselines))
+ assert.equal(createHash("sha256").update(readFileSync(repoFile(file))).digest("hex"), hash, file);
+ for (const name of ["cli", "webui"]) {
+ const source = readFileSync(repoFile(`packages/plugins/channel/${name}/index.mjs`), "utf8");
+ assert.doesNotMatch(source, /human-cli|humanTransport|humanCall|verifyHuman|readProcess|child_process|spawn/);
+ }
+});
+
+test("channel recovery still requires the same shim path and rejects an absent or forged proof", { timeout: 20000 }, async t => {
+ const s = await provedRuntime(t);
+ const direct = new Client({ path: s.socket });
+ await assert.rejects(direct.call("plugins"), e => ["invalid-envelope", "unauthenticated", "human-required"].includes(e.code));
+ const forged = new Client({ path: s.socket, human: { business: "demo", pid: process.pid, startTime: "0", nonce: "a".repeat(64) } });
+ await assert.rejects(forged.call("plugins"), e => e.code === "human-required");
+ const genuine = await s.shimCall(s.shim, { business: "demo", verb: "plugins" });
+ assert.equal(genuine.status, 0, genuine.stderr);
+ assert.deepEqual(JSON.parse(genuine.stdout).plugins.channel, []);
+ let other = readFileSync(s.shim, "utf8");
+ other = other.replace("'./client.mjs'", JSON.stringify(new URL("../src/client.mjs", import.meta.url).href));
+ other = other.replace("'./human.mjs'", JSON.stringify(new URL("../src/human.mjs", import.meta.url).href));
+ const differentPath = join(s.root, "other-human-cli.mjs");
+ writeFileSync(differentPath, other);
+ const refused = await s.shimCall(differentPath, { business: "demo", verb: "plugins" });
+ assert.notEqual(refused.status, 0);
+ assert.equal(refused.stderr.trim(), "human-required");
+});
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index 7b903e506..a56a1b118 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -17,6 +17,8 @@ import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } f
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
+import { createChannelCaller } from "../../plugins/src/channel.mjs";
+import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
@@ -186,6 +188,28 @@ function callFor(parsed, io, deps) {
return deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
}
+
+// Only the converted project/thread surface uses a channel. Management stays on callFor.
+function channelCallFor(parsed, io, deps) {
+ const call = callFor(parsed, io, deps);
+ const channel = createChannelCaller({ name: "cli", call,
+ log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
+ });
+ return async (verb, args = {}) => {
+ try { return await channel(verb, args); }
+ catch (error) {
+ if (!(error instanceof PluginError)) throw error;
+ let message = error.message;
+ if (error.code === "channel-disabled" && parsed.values["--business"]) {
+ message += ` Pass --business ${JSON.stringify(parsed.values["--business"])} to both commands.`;
+ }
+ const refused = new CliError(message, ["channel-disabled", "channel-config"].includes(error.code) ? 3 : 2);
+ refused.code = error.code;
+ throw refused;
+ }
+ };
+}
+
// The human's unread messages, newest first, from every page of `messages`.
async function unreadMessages(call) {
const messages = [];
@@ -342,7 +366,7 @@ async function talk(rest, io, deps, receiveOnly = false) {
if (threadRef !== undefined) [to, body] = [threadRef, to];
// The broker refuses it too (tester-prefix); this is the clear early message.
if (!receiveOnly && parsed.flags.has("--tester") && !body.startsWith(TESTER_PREFIX)) throw new CliError(`a tester message starts with "${TESTER_PREFIX}"`, 2);
- const call = callFor(parsed, io, deps);
+ const call = threadRef === undefined ? callFor(parsed, io, deps) : channelCallFor(parsed, io, deps);
let sent;
let thread = null;
if (threadRef !== undefined) {
@@ -538,9 +562,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
- if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => callFor(p, io, deps)) : projectCommand(rest, io, deps);
- if (verb === "threads") return threadsCommand(rest, io, (p) => callFor(p, io, deps));
- if (verb === "thread") return threadCommand(rest, io, (p) => callFor(p, io, deps), formatMessage);
+ if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps);
+ if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
+ if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
diff --git a/packages/cli/tests/channel-cli.test.mjs b/packages/cli/tests/channel-cli.test.mjs
new file mode 100644
index 000000000..8e6f1ee58
--- /dev/null
+++ b/packages/cli/tests/channel-cli.test.mjs
@@ -0,0 +1,42 @@
+// Row 164: switching CLI chat off never gates the core management verbs.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { run } from "../src/cli.mjs";
+import { io, tmp } from "./helpers.mjs";
+
+function setup(t, selected) {
+ const calls = [];
+ const deps = { system: { dataRoot: tmp(t, "channel-cli-") }, transport: () => async (verb, args = {}) => {
+ calls.push([verb, args]);
+ if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } };
+ if (["project.list", "thread.list"].includes(verb)) return [];
+ assert.fail("unexpected operation " + verb);
+ } };
+ return { calls, async cli(argv) { const x = io(); return { code: await run([...argv, "--business", "demo"], x, deps), out: x.out.text, err: x.err.text }; } };
+}
+
+test("CLI off, absent and other-only channel selections refuse every project/thread flow before any effect", async t => {
+ for (const selected of [[], undefined, ["webui"]]) {
+ const s = setup(t, selected);
+ for (const argv of [["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "stack/chat"], ["thread", "open", "stack/chat"], ["thread", "create", "stack", "chat"], ["talk", "--thread", "stack/chat", "hello", "--wait", "0"], ["follow", "--thread", "stack/chat"]]) {
+ s.calls.length = 0;
+ const result = await s.cli(argv);
+ assert.equal(result.code, 3, argv.join(" "));
+ assert.match(result.err, /mosaic plugins.*mosaic plugins set/s);
+ assert.match(result.err, /plugins\.channel/);
+ assert.ok(result.err.includes('Pass --business "demo" to both commands.'));
+ assert.match(result.err, /cli.*webui/);
+ assert.equal(result.out, "");
+ assert.deepEqual(s.calls, [["plugins", {}]]);
+ }
+ }
+});
+
+test("CLI enabled project/thread lists dispatch through selection without changing output", async t => {
+ const s = setup(t, ["cli"]);
+ assert.deepEqual(await s.cli(["project", "list"]), { code: 0, out: "no projects\n", err: "" });
+ assert.deepEqual(s.calls, [["plugins", {}], ["project.list", {}]]);
+ s.calls.length = 0;
+ assert.deepEqual(await s.cli(["threads"]), { code: 0, out: "no threads\n", err: "" });
+ assert.deepEqual(s.calls, [["plugins", {}], ["thread.list", {}]]);
+});
diff --git a/packages/cli/tests/channel-fixture.mjs b/packages/cli/tests/channel-fixture.mjs
new file mode 100644
index 000000000..b3a04d9b3
--- /dev/null
+++ b/packages/cli/tests/channel-fixture.mjs
@@ -0,0 +1,75 @@
+// Private fixture runtime only; no live host, unit, store or organization file.
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { fixture, BUSINESSES } from "./helpers.mjs";
+import { systemFor } from "../../business/tests/helpers.mjs";
+
+export async function childResult(file, args, env, input) {
+ const child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] });
+ let stdout = "", stderr = "";
+ child.stdout.on("data", b => stdout += b);
+ child.stderr.on("data", b => stderr += b);
+ child.stdin.on("error", () => {});
+ child.stdin.end(input);
+ const [status, signal] = await once(child, "close");
+ return { status, signal, stdout, stderr };
+}
+
+export async function provedRuntime(t) {
+ const root = mkdtempSync(join(tmpdir(), "channel-recovery-"));
+ let host;
+ t.after(async () => {
+ try {
+ if (host && host.exitCode === null && host.signalCode === null) {
+ const done = once(host, "close");
+ const timer = setTimeout(() => host.kill("SIGKILL"), 3000);
+ host.kill("SIGTERM");
+ try { await done; } finally { clearTimeout(timer); }
+ }
+ } finally { rmSync(root, { recursive: true, force: true }); }
+ });
+ const f = fixture(root, "demo", doc => ({ ...doc, plugins: { channel: [] } }));
+ mkdirSync(f.dataRoot, { mode: 0o700 });
+ const url = rel => new URL(rel, import.meta.url).href;
+ const humanURL = url("../../bus/src/human.mjs");
+ const script = join(root, "fixture-runtime.mjs");
+ // As in stack-copy.test.mjs, isolate the fixture's ancestry from the seat.
+ // The real PID, UID, argv path, start time, nonce and verifier still run.
+ writeFileSync(script, `
+import { mock } from 'node:test';
+const real = await import(${JSON.stringify(humanURL)});
+mock.module(${JSON.stringify(humanURL)}, { namedExports: {
+ readProcess: real.readProcess,
+ verifyHuman: (proof, options) => real.verifyHuman(proof, { ...options,
+ readProcess: pid => ({ ...real.readProcess(pid), ppid: 1 }) }),
+} });
+const { startBroker } = await import(${JSON.stringify(url("../../bus/src/runtime.mjs"))});
+const runtime = await startBroker({ dataRoot: ${JSON.stringify(f.dataRoot)},
+ businesses: ${JSON.stringify(BUSINESSES)},
+ organization: { dir: ${JSON.stringify(join(root, "config"))}, rolesDir: ${JSON.stringify(f.roles)}, system: ${JSON.stringify(systemFor(root))} } });
+process.on('SIGTERM', async () => { await runtime.close(); process.exit(0); });
+console.log('fixture ready');
+`);
+ const env = { PATH: process.env.PATH, HOME: root, TMPDIR: process.env.TMPDIR,
+ MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles };
+ host = spawn(process.execPath, ["--experimental-test-module-mocks", script], { env, stdio: ["ignore", "pipe", "pipe"] });
+ let output = "", errors = "";
+ host.stderr.on("data", b => errors += b);
+ await new Promise((resolve, reject) => {
+ host.stdout.on("data", b => { output += b; if (output.includes("fixture ready")) resolve(); });
+ host.once("exit", code => reject(new Error(`fixture failed ${code}: ${errors}`)));
+ host.once("error", reject);
+ });
+ const cli = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
+ const shim = fileURLToPath(new URL("../../bus/src/human-cli.mjs", import.meta.url));
+ return { root, f, env, shim, socket: join(f.dataRoot, "bus/broker.sock"),
+ cli: argv => childResult(process.execPath, [cli, ...argv, "--business", "demo"], env),
+ shimCall: (file, request) => childResult(process.execPath, [file, join(f.dataRoot, "bus/broker.sock")], env, JSON.stringify(request) + "\n"),
+ file: join(root, "config/businesses/demo.json"),
+ history: () => readFileSync(join(root, "config/businesses/.history/demo/changes.jsonl"), "utf8").trim().split("\n").map(JSON.parse),
+ };
+}
diff --git a/packages/cli/tests/channel-recovery.test.mjs b/packages/cli/tests/channel-recovery.test.mjs
new file mode 100644
index 000000000..e0998413b
--- /dev/null
+++ b/packages/cli/tests/channel-recovery.test.mjs
@@ -0,0 +1,39 @@
+// Removing or disabling every channel must leave the proved management writer usable.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { writeFileSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { provedRuntime } from "./channel-fixture.mjs";
+
+test("both channels off recover through the existing human CLI, socket and organization writer", { timeout: 20000 }, async t => {
+ const s = await provedRuntime(t);
+ const off = await s.cli(["threads"]);
+ assert.equal(off.status, 3, off.stderr);
+ assert.match(off.stderr, /mosaic plugins.*mosaic plugins set/s);
+ const view = await s.cli(["plugins", "--json"]);
+ assert.equal(view.status, 0, view.stderr);
+ const state = JSON.parse(view.stdout);
+ assert.deepEqual(state.plugins.channel, []);
+ const patch = join(s.root, "enable.json");
+ writeFileSync(patch, JSON.stringify({ revision: state.revision, plugins: { channel: ["cli", "webui"] } }));
+ const done = await s.cli(["plugins", "set", "--file", patch]);
+ assert.equal(done.status, 0, done.stderr);
+ assert.deepEqual(JSON.parse(done.stdout).plugins.channel, ["cli", "webui"]);
+ assert.deepEqual(JSON.parse(readFileSync(s.file, "utf8")).plugins.channel, ["cli", "webui"]);
+ const receipt = s.history().at(-1);
+ assert.equal(receipt.actor, "jason");
+ assert.equal(receipt.state, "committed");
+ const threads = await s.cli(["threads"]);
+ assert.deepEqual([threads.status, threads.stdout, threads.stderr], [0, "no threads\n", ""]);
+ const before = readFileSync(s.file, "utf8");
+ const stale = await s.cli(["plugins", "set", "--file", patch]);
+ assert.notEqual(stale.status, 0);
+ assert.match(stale.stderr, /revision-conflict/);
+ assert.equal(readFileSync(s.file, "utf8"), before);
+ // Removing the key is also off, and does not remove the recovery read path.
+ const now = JSON.parse((await s.cli(["plugins"])).stdout);
+ writeFileSync(patch, JSON.stringify({ revision: now.revision, plugins: { channel: null } }));
+ assert.equal((await s.cli(["plugins", "set", "--file", patch])).status, 0);
+ assert.equal((await s.cli(["threads"])).status, 3);
+ assert.equal((await s.cli(["plugins"])).status, 0);
+});
diff --git a/packages/cli/tests/project.test.mjs b/packages/cli/tests/project.test.mjs
index c101ba5e7..5e067247b 100644
--- a/packages/cli/tests/project.test.mjs
+++ b/packages/cli/tests/project.test.mjs
@@ -44,11 +44,12 @@ test('project create and list validate their arguments before any bus call', asy
assert.match(x.err.text, /mosaic project create <name>/);
}
const listed = [];
- const transport = () => async verb => (listed.push(verb), []);
+ const managementReads = [], transport = () => async verb => verb === 'plugins' ? (managementReads.push(verb), { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }) : (listed.push(verb), []);
const x = io(humanEnv(f.env));
assert.equal(await run(['project', 'list', '--business', 'acme'], x, { transport }), 0);
assert.deepEqual(listed, ['project.list']);
assert.equal(x.out.text, 'no projects\n');
+ assert.deepEqual(managementReads, ['plugins']);
});
test('project dispatcher reaches the human CLI', async t => {
diff --git a/packages/cli/tests/thread-cli.test.mjs b/packages/cli/tests/thread-cli.test.mjs
index 47e557eaf..13e32bc86 100644
--- a/packages/cli/tests/thread-cli.test.mjs
+++ b/packages/cli/tests/thread-cli.test.mjs
@@ -32,7 +32,13 @@ function setup(t, { businessRoles = roles } = {}) {
store.close();
rmSync(root, { recursive: true, force: true });
});
+ const managementReads = [];
const b = new Broker({
+ organization: (business, verb) => {
+ assert.equal(verb, "plugins");
+ managementReads.push([business, verb]);
+ return { business, revision: "a".repeat(64), plugins: { channel: ["cli", "webui"] } };
+ },
store,
businesses: { demo: { id: "demo", human: "jason", arbiters: { technical: "chief", delivery: "chief" }, roles: businessRoles, definitions, projects: { stack: { root: "/nonexistent" } } } },
});
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
index 97703951f..9f8b86d62 100644
--- a/packages/plugins/README.md
+++ b/packages/plugins/README.md
@@ -19,6 +19,7 @@ loader.
| `usage-source` | 1 | list | `interfaceVersion`, `readTurn` |
| `limit-check` | 1 | list | `interfaceVersion`, `mayLaunch`, `mustStop` |
| `search-source` | 1 | one | `interfaceVersion`, `search` |
+| `channel` | 1 | list | `interfaceVersion`, `dispatch` |
Every module exports `interfaceVersion`, equal to its kind's interface
version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds
@@ -131,3 +132,40 @@ and appends baselines and outcomes. See that module's README for JSONL formats,
security/resource bounds and the best-reading limitation. No limit check ships
with this row. Enable transcript through the human plug-in settings writer;
a restart/re-pin remains a separate authorized window.
+
+## Human project/thread channels
+
+`channel` modules carry project/thread operations between a human surface and
+its existing core bus caller. The shipped `cli` and `webui` modules implement
+`dispatch({ verb, args }, { call })`, returning the bus result or refusal
+unchanged. Only the fixed project/thread operations are supported; CLI also
+uses message.receive/read for thread talk/follow. Modules do not create a human
+proof, choose a business/owner, spawn the shim or write organization settings.
+The broker still sees the proved transport as `via: 'cli'` for both surfaces.
+Discord is not connected and no Discord module ships here.
+
+The setting is `"plugins": { "channel": ["cli", "webui"] }`. An absent key or
+an empty list disables both converted chat surfaces. No silent default enables
+them. CLI off refuses with exit 3; web chat off returns 503, both naming recovery.
+Other CLI administration and Console views are independent. CLI project
+init/check and non-thread talk/inbox are core operations, outside this conversion.
+
+Each chat operation reads the existing plugins setting, loads only its channel
+selection through the in-tree loader, captures a frozen `snapshot('channel')`
+array of `{ name, module }`, then dispatches without a lock. A slow send does
+not block a list. A captured array never changes after a reload; loaded() and
+modules() keep their existing interfaces. Invalid module settings retain the
+loader's last good selection; the first refused load has nothing enabled.
+
+The recovery commands never need a channel: from a human shell, run
+`mosaic plugins --business BUSINESS --json`, make a patch with its revision and
+`{"channel":["cli","webui"]}` as the plugins value, then run
+`mosaic plugins set --business BUSINESS --file PATCH`. This uses the existing
+proved human transport and organization writer, with revision checks and actor
+receipts. The next chat operation reads the restored selection. Disabling a
+channel cannot disable this write path. It is not a repair bypass for an invalid
+organization file or unavailable bus.
+
+For the re-pin introducing this kind, Jason enables both channels through
+plugins.set immediately after activate in Mos's window, then smokes CLI thread
+listing and Console chat. No development seat performs that live step.
diff --git a/packages/plugins/channel/cli/index.mjs b/packages/plugins/channel/cli/index.mjs
new file mode 100644
index 000000000..d74347edb
--- /dev/null
+++ b/packages/plugins/channel/cli/index.mjs
@@ -0,0 +1,19 @@
+// Surface adapter only. The caller and its human proof belong to core.
+import { PluginError } from "../../src/kinds.mjs";
+export const interfaceVersion = 1;
+const VERBS = new Set([
+ "project.list",
+ "project.create",
+ "thread.list",
+ "thread.create",
+ "thread.messages",
+ "thread.send",
+ "thread.open",
+ "message.receive",
+ "message.read",
+]);
+
+export async function dispatch({ verb, args }, { call }) {
+ if (!VERBS.has(verb)) throw new PluginError("channel-operation", "unsupported channel operation");
+ return call(verb, args);
+}
diff --git a/packages/plugins/channel/webui/index.mjs b/packages/plugins/channel/webui/index.mjs
new file mode 100644
index 000000000..e9cb53f15
--- /dev/null
+++ b/packages/plugins/channel/webui/index.mjs
@@ -0,0 +1,17 @@
+// Surface adapter only. The caller and its human proof belong to core.
+import { PluginError } from "../../src/kinds.mjs";
+export const interfaceVersion = 1;
+const VERBS = new Set([
+ "project.list",
+ "project.create",
+ "thread.list",
+ "thread.create",
+ "thread.messages",
+ "thread.send",
+ "thread.open",
+]);
+
+export async function dispatch({ verb, args }, { call }) {
+ if (!VERBS.has(verb)) throw new PluginError("channel-operation", "unsupported channel operation");
+ return call(verb, args);
+}
diff --git a/packages/plugins/src/channel.mjs b/packages/plugins/src/channel.mjs
new file mode 100644
index 000000000..84c54c091
--- /dev/null
+++ b/packages/plugins/src/channel.mjs
@@ -0,0 +1,28 @@
+// Core selection only: modules receive a business-bound call, never a proof or writer.
+import { createLoader } from "./loader.mjs";
+import { NAME, PluginError } from "./kinds.mjs";
+
+export const CHANNEL_RECOVERY = 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
+const record = value => value !== null && typeof value === "object" && !Array.isArray(value);
+const report = entry => process.stderr.write(`channel: ${JSON.stringify({
+ event: entry.event, kind: entry.kind, name: entry.name, code: entry.code, kept: entry.kept,
+})}\n`);
+
+export function createChannelCaller({ name, call, log = report }) {
+ if (typeof name !== "string" || !NAME.test(name)) throw new PluginError("bad-name", "invalid channel name");
+ if (typeof call !== "function") throw new TypeError("channel needs a core caller");
+ const loader = createLoader({ log });
+ return async (verb, args = {}) => {
+ const settings = await call("plugins", {});
+ if (!record(settings) || !record(settings.plugins)) {
+ throw new PluginError("channel-config", "invalid channel settings response");
+ }
+ const selection = Object.hasOwn(settings.plugins, "channel") ? { channel: settings.plugins.channel } : {};
+ await loader.load(selection);
+ const snapshot = loader.snapshot("channel");
+ const entry = snapshot.find(item => item.name === name);
+ if (!entry) throw new PluginError("channel-disabled", CHANNEL_RECOVERY, name);
+ // No lock spans dispatch. Concurrent reads can proceed while a send awaits.
+ return entry.module.dispatch({ verb, args }, { call });
+ };
+}
diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs
index f5679ad3f..a29681573 100644
--- a/packages/plugins/src/kinds.mjs
+++ b/packages/plugins/src/kinds.mjs
@@ -22,6 +22,10 @@ export const KINDS = Object.freeze({
"search-source": Object.freeze({
kind: "search-source", interfaceVersion: 1, exports: Object.freeze(["search"]), switch: "one",
}),
+ // Human project/thread surfaces; proof and the recovery writer remain in core.
+ channel: Object.freeze({
+ kind: "channel", interfaceVersion: 1, exports: Object.freeze(["dispatch"]), switch: "list",
+ }),
});
export class PluginError extends Error {
diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs
index 5a8aa8b5e..04731ee91 100644
--- a/packages/plugins/src/loader.mjs
+++ b/packages/plugins/src/loader.mjs
@@ -154,5 +154,11 @@ export function createLoader({ log, barrier = () => {} }) {
return current.get(kind).map(m => m.module);
}
- return { load, loaded, modules };
+ // Capture names and modules together, without exposing mutable state or a dispatch lock.
+ function snapshot(kind) {
+ if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
+ return current.get(kind);
+ }
+
+ return { load, loaded, modules, snapshot };
}
diff --git a/packages/plugins/tests/channel.test.mjs b/packages/plugins/tests/channel.test.mjs
new file mode 100644
index 000000000..b44856580
--- /dev/null
+++ b/packages/plugins/tests/channel.test.mjs
@@ -0,0 +1,233 @@
+// Row 164: channel selection stays independent of human proof and administration.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
+import { join } from "node:path";
+import { createLoader } from "../src/loader.mjs";
+import { KINDS } from "../src/kinds.mjs";
+import { tree } from "./helpers.mjs";
+
+const code = expected => e => e.code === expected;
+const fixtureModule = tag => `export const interfaceVersion = 1;
+export async function dispatch(request, {call}) { return call(request.verb, {...request.args, fixture:${JSON.stringify(tag)}}); }
+`;
+const settings = channel => ({ business: "demo", revision: "a".repeat(64), plugins: channel === undefined ? {} : { channel } });
+const helper = () => import("../src/channel.mjs");
+
+test("channel registry requires dispatch at interface 1 and switches a list", () => {
+ assert.deepEqual(KINDS.channel, { kind: "channel", interfaceVersion: 1, exports: ["dispatch"], switch: "list" });
+ assert.ok(Object.isFrozen(KINDS.channel));
+ assert.ok(Object.isFrozen(KINDS.channel.exports));
+});
+
+test("channel shipped modules load and preserve every supported operation and bus refusal", async () => {
+ const loader = createLoader({ log: () => {} });
+ assert.deepEqual((await loader.load({ channel: ["cli", "webui"] })).refused, []);
+ assert.deepEqual(loader.loaded().channel, ["cli", "webui"]);
+ const modules = loader.modules("channel");
+ for (const [index, verbs] of [
+ [0, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "message.receive", "message.read"]],
+ [1, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open"]],
+ ]) {
+ for (const verb of verbs) {
+ const seen = [];
+ const result = await modules[index].dispatch({ verb, args: { thread: "thread-one", body: "literal", class: "REQUEST" } }, {
+ call: async (v, args) => { seen.push([v, args]); return { receipt: "preserved" }; },
+ });
+ assert.deepEqual(result, { receipt: "preserved" });
+ assert.deepEqual(seen, [[verb, { thread: "thread-one", body: "literal", class: "REQUEST" }]]);
+ }
+ const refusal = Object.assign(new Error("thread-not-owned"), { code: "thread-not-owned" });
+ await assert.rejects(modules[index].dispatch({ verb: "thread.list", args: {} }, { call: async () => { throw refusal; } }), e => e === refusal);
+ let calls = 0;
+ await assert.rejects(modules[index].dispatch({ verb: "plugins.set", args: {} }, { call: async () => calls++ }), code("channel-operation"));
+ assert.equal(calls, 0);
+ }
+ await assert.rejects(modules[1].dispatch({ verb: "message.receive", args: {} }, { call: async () => assert.fail("not a web chat operation") }), code("channel-operation"));
+});
+
+test("channel snapshot is a frozen matched pair and a captured array survives reload", async t => {
+ const s = tree(t);
+ s.write("channel", "one", fixtureModule("one"));
+ s.write("channel", "two", fixtureModule("two"));
+ const loader = await s.loader();
+ await loader.load({ channel: ["two", "one"] });
+ assert.equal(typeof loader.snapshot, "function");
+ const captured = loader.snapshot("channel");
+ assert.deepEqual(captured.map(x => x.name), ["two", "one"]);
+ assert.deepEqual(captured.map(x => x.name), loader.loaded().channel);
+ assert.deepEqual(captured.map(x => x.module), loader.modules("channel"));
+ assert.ok(Object.isFrozen(captured));
+ assert.ok(captured.every(Object.isFrozen));
+ assert.throws(() => captured.push(captured[0]), TypeError);
+ await loader.load({ channel: ["one"] });
+ assert.deepEqual(loader.snapshot("channel").map(x => x.name), ["one"]);
+ assert.deepEqual(captured.map(x => x.name), ["two", "one"]);
+ assert.deepEqual(await captured[0].module.dispatch({ verb: "thread.list", args: {} }, { call: async (verb, args) => args }), { fixture: "two" });
+ assert.throws(() => loader.snapshot("no-such-kind"), code("unknown-kind"));
+});
+
+test("channel guards refuse bad names, missing paths, exports and versions while retaining last good", async t => {
+ const s = tree(t);
+ s.write("channel", "good", fixtureModule("good"));
+ mkdirSync(join(s.pkg, "channel", "empty"));
+ mkdirSync(join(s.pkg, "channel", "dir-index", "index.mjs"), { recursive: true });
+ s.write("channel", "missing-export", "export const interfaceVersion = 1;\n");
+ s.write("channel", "missing-version", "export function dispatch() {}\n");
+ s.write("channel", "bad-type", "export const interfaceVersion = 1; export const dispatch = 1;\n");
+ s.write("channel", "wrong-version", "export const interfaceVersion = 2; export function dispatch() {}\n");
+ const loader = await s.loader();
+ assert.deepEqual((await loader.load({ channel: ["good"] })).refused, []);
+ for (const [names, expected] of [
+ [["../good"], "bad-name"], [["absent"], "missing"], [["empty"], "missing"], [["dir-index"], "missing"],
+ [["missing-export"], "missing-export"], [["missing-version"], "missing-export"], [["bad-type"], "missing-export"], [["wrong-version"], "interface-version"],
+ ]) {
+ const result = await loader.load({ channel: names });
+ assert.equal(result.refused.length, 1);
+ assert.equal(result.refused[0].code, expected);
+ assert.deepEqual(result.loaded.channel, ["good"]);
+ assert.deepEqual(result.refused[0].kept, ["good"]);
+ }
+});
+
+test("channel guards refuse symlinked module, index and kind directories", async t => {
+ const s = tree(t);
+ const good = s.write("channel", "good", fixtureModule("good"));
+ const loader = await s.loader();
+ await loader.load({ channel: ["good"] });
+ symlinkSync(good, join(s.pkg, "channel", "linked"));
+ const index = s.write("channel", "linked-index", fixtureModule("unused"));
+ unlinkSync(join(index, "index.mjs"));
+ symlinkSync(join(good, "index.mjs"), join(index, "index.mjs"));
+ for (const name of ["linked", "linked-index"]) {
+ const result = await loader.load({ channel: [name] });
+ assert.equal(result.refused[0].code, "symlink");
+ const refusedPath = `/channel/${name}${name === "linked-index" ? "/index.mjs" : ""} is a symlink`;
+ assert.ok(result.refused[0].message.endsWith(refusedPath));
+ assert.deepEqual(result.loaded.channel, ["good"]);
+ }
+ renameSync(join(s.pkg, "channel"), join(s.pkg, "channel-real"));
+ symlinkSync(join(s.pkg, "channel-real"), join(s.pkg, "channel"));
+ assert.equal((await loader.load({ channel: ["good"] })).refused[0].code, "symlink");
+});
+
+test("channel caller re-reads settings and keeps off switches independent by module name", async () => {
+ const { createChannelCaller } = await helper();
+ let selected = ["webui"];
+ const calls = [], logged = [];
+ const call = async (verb, args) => {
+ calls.push([verb, args]);
+ return verb === "plugins" ? settings(selected) : { ok: true };
+ };
+ const cli = createChannelCaller({ name: "cli", call, log: entry => logged.push(entry) });
+ const webui = createChannelCaller({ name: "webui", call, log: entry => logged.push(entry) });
+ await assert.rejects(cli("thread.list", {}), e => e.code === "channel-disabled" && /mosaic plugins.*mosaic plugins set/s.test(e.message));
+ assert.deepEqual(calls.splice(0), [["plugins", {}]]);
+ assert.deepEqual(await webui("thread.list", {}), { ok: true });
+ assert.deepEqual(calls.splice(0), [["plugins", {}], ["thread.list", {}]]);
+ selected = ["cli"];
+ assert.deepEqual(await cli("project.list", {}), { ok: true });
+ calls.length = 0;
+ await assert.rejects(webui("thread.send", { body: "private" }), code("channel-disabled"));
+ assert.deepEqual(calls, [["plugins", {}]]);
+ for (selected of [[], undefined]) {
+ calls.length = 0;
+ await assert.rejects(cli("message.receive", {}), code("channel-disabled"));
+ assert.deepEqual(calls, [["plugins", {}]]);
+ }
+ assert.deepEqual(logged, []);
+});
+
+test("channel caller malformed settings refuse without a chat operation and no unrelated kind loads", async () => {
+ const { createChannelCaller } = await helper();
+ let answer = null;
+ let ops = 0;
+ const logged = [];
+ const call = async verb => verb === "plugins" ? answer : ++ops;
+ const caller = createChannelCaller({ name: "cli", call, log: e => logged.push(e) });
+ for (answer of [null, [], {}, { plugins: null }, { plugins: [] }]) {
+ await assert.rejects(caller("project.list", {}), code("channel-config"));
+ assert.equal(ops, 0);
+ }
+ answer = { ...settings(["cli"]), plugins: { channel: ["cli"], "usage-source": ["missing-unrelated"] } };
+ assert.equal(await caller("project.list", {}), 1);
+ assert.deepEqual(logged, []);
+});
+
+test("channel caller uses last good after loader refusal and fails closed on first load", async () => {
+ const { createChannelCaller } = await helper();
+ let selected = ["cli"];
+ const logged = [];
+ const call = async verb => verb === "plugins" ? settings(selected) : "kept";
+ const caller = createChannelCaller({ name: "cli", call, log: e => logged.push(e) });
+ assert.equal(await caller("project.list", {}), "kept");
+ selected = ["missing-module"];
+ assert.equal(await caller("project.list", {}), "kept");
+ assert.equal(logged[0].code, "missing");
+ assert.deepEqual(logged[0].kept, ["cli"]);
+ const fresh = createChannelCaller({ name: "cli", call, log: () => {} });
+ await assert.rejects(fresh("project.list", {}), code("channel-disabled"));
+});
+
+test("channel dispatch holds no selection lock while a slow send is awaiting", async () => {
+ const { createChannelCaller } = await helper();
+ let release, started;
+ const entered = new Promise(r => started = r);
+ const held = new Promise(r => release = r);
+ const caller = createChannelCaller({ name: "cli", log: () => {}, call: async verb => {
+ if (verb === "plugins") return settings(["cli"]);
+ if (verb === "thread.send") { started(); await held; return "sent"; }
+ return "listed";
+ } });
+ const send = caller("thread.send", {});
+ await entered;
+ let timer;
+ try {
+ assert.equal(await Promise.race([caller("project.list", {}), new Promise((_, reject) => timer = setTimeout(() => reject(new Error("dispatch blocked list")), 1000))]), "listed");
+ } finally { clearTimeout(timer); release(); }
+ assert.equal(await send, "sent");
+});
+
+test("channel last realpath check refuses an index swapped after component checks", async t => {
+ const s = tree(t);
+ const good = s.write("channel", "good", fixtureModule("good"));
+ const swapped = s.write("channel", "swapped", fixtureModule("old"));
+ let swap = false;
+ const loader = await s.loader({ barrier: (point, { name }) => {
+ if (swap && point === "checked" && name === "swapped") {
+ unlinkSync(join(swapped, "index.mjs"));
+ symlinkSync(join(good, "index.mjs"), join(swapped, "index.mjs"));
+ }
+ } });
+ await loader.load({ channel: ["good"] });
+ swap = true;
+ const result = await loader.load({ channel: ["swapped"] });
+ assert.equal(result.refused[0].code, "symlink");
+ assert.deepEqual(result.loaded.channel, ["good"]);
+});
+
+test("channel caller refuses invalid core construction", async () => {
+ const { createChannelCaller } = await helper();
+ for (const name of ["../cli", "Cli", null])
+ assert.throws(() => createChannelCaller({ name, call: async () => ({}) }), code("bad-name"));
+ assert.throws(() => createChannelCaller({ name: "cli", call: null }), TypeError);
+});
+
+test("channel caller invokes the selected loaded module instead of bypassing dispatch", async t => {
+ const s = tree(t);
+ s.write("channel", "cli", fixtureModule("cli-fixture"));
+ s.write("channel", "webui", fixtureModule("webui-fixture"));
+ const { createChannelCaller } = await import(new URL("./channel.mjs", s.loaderUrl));
+ const caller = createChannelCaller({ name: "webui", log: () => {}, call: async (verb, args) =>
+ verb === "plugins" ? settings(["cli", "webui"]) : args });
+ assert.deepEqual(await caller("thread.list", { project: "one" }), { project: "one", fixture: "webui-fixture" });
+});
+
+test("channel caller keeps the selected module operation filter in front of the core transport", async () => {
+ const { createChannelCaller } = await helper();
+ let writes = 0;
+ const caller = createChannelCaller({ name: "cli", log: () => {}, call: async verb =>
+ verb === "plugins" ? settings(["cli"]) : ++writes });
+ await assert.rejects(caller("plugins.set", {}), code("channel-operation"));
+ assert.equal(writes, 0);
+});
diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs
index 9bed0dee8..14c72e311 100644
--- a/packages/plugins/tests/kinds.test.mjs
+++ b/packages/plugins/tests/kinds.test.mjs
@@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected;
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source and limit-check at interface version 1, each a list", () => {
- assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source"]);
+ assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel"]);
assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] },
{ kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" });
assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] },
diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs
index 2cd0068ed..b4b02eb44 100644
--- a/packages/plugins/tests/loader.test.mjs
+++ b/packages/plugins/tests/loader.test.mjs
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../business/src/business.mjs";
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
-const GOOD_LOADED = { ...GOOD, "search-source": [] };
+const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [] };
// A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) {
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
const loader = await s.loader();
const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []);
- assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [] });
+ assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused);
- assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [] });
+ assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [] });
} finally {
if (lock) chmodSync(lock, 0o755);
}
@@ -214,7 +214,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
- assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [] });
+ assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1);
@@ -242,7 +242,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] });
- assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [] });
+ assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
});
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index 94f765482..5a92dcff8 100644
--- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs
@@ -10,6 +10,7 @@ const json = (res, status, value) => {
res.end(JSON.stringify(value));
};
const STATUS = {
+ 'channel-disabled': 503,
'invalid-request': 400, 'unknown-repo': 400, 'message-not-found': 400,
'project-not-found': 404, 'thread-not-found': 404, 'thread-not-owned': 403,
'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
@@ -17,6 +18,7 @@ const STATUS = {
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
};
function safeMessage(code) {
+ if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
return `Chat operation refused (${code}).`;
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 85d3cda92..2cf33ee78 100644
--- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs
@@ -1,14 +1,26 @@
// Row 129: the single seam between Console chat operations and the current human transport.
// No browser-selected bus verb, tenant, owner, capability or message class crosses this seam.
+import { createChannelCaller } from '../../plugins/src/channel.mjs';
+import { PluginError } from '../../plugins/src/kinds.mjs';
+import { BusReadError } from './bus.mjs';
+
export function createChat({ call }) {
if (typeof call !== 'function') throw new TypeError('chat needs a human transport');
+ const dispatch = createChannelCaller({ name: 'webui', call });
+ const channel = async (verb, args) => {
+ try { return await dispatch(verb, args); }
+ catch (err) {
+ if (err instanceof PluginError) throw new BusReadError(err.code, err.message);
+ throw err;
+ }
+ };
return Object.freeze({
- projects: () => call('project.list', {}),
- createProject: ({ name, repo }) => call('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
- threads: ({ project } = {}) => call('thread.list', project === undefined ? {} : { project }),
- createThread: ({ project, name }) => call('thread.create', { project, name }),
- messages: ({ thread, after, limit }) => call('thread.messages', { thread, ...(after ? { after } : {}), limit }),
- send: ({ thread, body }) => call('thread.send', { thread, body, class: 'REQUEST' }),
- open: ({ thread }) => call('thread.open', { thread }),
+ projects: () => channel('project.list', {}),
+ createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
+ threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
+ createThread: ({ project, name }) => channel('thread.create', { project, name }),
+ messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
+ send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
+ open: ({ thread }) => channel('thread.open', { thread }),
});
}
diff --git a/packages/webui/tests/channel-chat.test.mjs b/packages/webui/tests/channel-chat.test.mjs
new file mode 100644
index 000000000..c550cd39b
--- /dev/null
+++ b/packages/webui/tests/channel-chat.test.mjs
@@ -0,0 +1,83 @@
+// Row 164: the chat switch and fixed recovery message stay local to chat HTTP.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { request } from "node:http";
+import { createChat } from "../src/chat.mjs";
+import { startServer } from "../src/serve.mjs";
+import { BusReadError } from "../src/bus.mjs";
+import { close } from "./fixture.mjs";
+const P = "10000000-0000-4000-8000-000000000001";
+const T = "20000000-0000-4000-8000-000000000002";
+const preview = { host: "chat.example.com", header: "remote-user", user: "jason" };
+const headers = { host: preview.host, "remote-user": "jason", origin: "https://chat.example.com", "sec-fetch-site": "same-origin", "content-type": "application/json" };
+
+
+function http(port, path, body) {
+ const raw = body === undefined ? undefined : JSON.stringify(body);
+ return new Promise((resolve, reject) => {
+ const req = request({ host: '127.0.0.1', port, path, method: raw === undefined ? 'GET' : 'POST',
+ headers: { ...headers, ...(raw === undefined ? {} : { 'content-length': Buffer.byteLength(raw) }) },
+ }, res => {
+ let text = '';
+ res.on('data', chunk => text += chunk);
+ res.on('end', () => resolve({ status: res.statusCode, data: JSON.parse(text) }));
+ });
+ req.on('error', reject); req.end(raw);
+ });
+}
+
+async function setup(t, selected) {
+ const calls = [], logs = [];
+ const chat = createChat({ call: async (verb, args) => {
+ calls.push([verb, args]);
+ if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } };
+ return [];
+ } });
+ const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: line => logs.push(line) });
+ t.after(() => close(server));
+ return { calls, logs, async request(path, body) {
+ return http(server.address().port, path, body);
+ } };
+}
+
+test("WebUI channel off returns 503 and named recovery for every chat route without a chat operation", async t => {
+ for (const selected of [[], undefined, ["cli"]]) {
+ const s = await setup(t, selected);
+ for (const [path, body] of [
+ ["/api/chat/projects"], ["/api/chat/threads"], [`/api/chat/threads/${T}/messages`],
+ ["/api/chat/projects", { name: "work" }], ["/api/chat/threads", { project: P, name: "chat" }],
+ [`/api/chat/threads/${T}/messages`, { body: "private body" }], [`/api/chat/threads/${T}/open`, {}],
+ ]) {
+ s.calls.length = 0;
+ const res = await s.request(path, body);
+ assert.equal(res.status, 503);
+ assert.equal(res.data.error, "channel-disabled");
+ assert.match(res.data.message, /mosaic plugins.*mosaic plugins set/s);
+ assert.match(res.data.message, /plugins\.channel/);
+ assert.match(res.data.message, /cli.*webui/);
+ if (body !== undefined) assert.equal(res.data.sent, false);
+ assert.deepEqual(s.calls, [["plugins", {}]]);
+ assert.doesNotMatch(JSON.stringify(res.data), /private body/);
+ }
+ }
+});
+
+test("WebUI only selection keeps web chat enabled while retaining the old read envelope", async t => {
+ const s = await setup(t, ["webui"]);
+ const res = await s.request("/api/chat/projects");
+ assert.equal(res.status, 200);
+ assert.deepEqual(res.data.rows, []);
+ assert.deepEqual(s.calls, [["plugins", {}], ["project.list", {}]]);
+});
+
+test("WebUI channel-disabled recovery text is fixed and never echoes transport text", async t => {
+ const chat = createChat({ call: async () => { throw new BusReadError("channel-disabled", "SENTINEL-private-input"); } });
+ const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} });
+ t.after(() => close(server));
+ const res = await http(server.address().port, '/api/chat/projects');
+ const result = res.data;
+ assert.equal(res.status, 503);
+ assert.equal(result.error, "channel-disabled");
+ assert.match(result.message, /mosaic plugins.*mosaic plugins set/s);
+ assert.doesNotMatch(JSON.stringify(result), /SENTINEL/);
+});
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 00ee8326a..5b6611bb3 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -33,7 +33,12 @@ function http(port, path, { method = 'GET', value, raw, extra = {}, host = previ
async function setup(t, options = {}) {
const calls = [], logs = [];
const state = { answer: [] };
+ const managementReads = [];
const chat = createChat({ call: async (verb, args) => {
+ if (verb === 'plugins') {
+ managementReads.push([verb, args]);
+ return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
+ }
calls.push([verb, args]);
if (state.answer instanceof Error) throw state.answer;
return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer;
@@ -189,7 +194,12 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
let b = new Broker({ store, businesses });
let cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const calls = [];
+ const managementReads = [];
const chat = createChat({ call: async (verb, args) => {
+ if (verb === 'plugins') {
+ managementReads.push([verb, args]);
+ return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
+ }
calls.push(verb);
try { return structuredClone(await b.request(cap, { verb, args })); }
catch (err) { throw new BusReadError(err.code); }
+1
View File
@@ -0,0 +1 @@
CLEAR
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Lists node/chromium/bash-gate processes with their cwd; exit 1 if any test-like ones run.
n=0
for p in /proc/[0-9]*; do
pid=${p#/proc/}; [ "$pid" = "$$" ] && continue
[ "$pid" = "2175820" ] && continue
comm=$(cat $p/comm 2>/dev/null) || continue
case "$comm" in node|chromium*|chrome|headless_shell) ;; bash) grep -qaE 'gate\.sh|mutants|test-[a-z-]+\.sh' $p/cmdline 2>/dev/null || continue ;; *) continue ;; esac
cwd=$(readlink $p/cwd 2>/dev/null)
case "$cwd" in */mosaic-stack*|/mnt/storage/scratch/*|/tmp/*|"$HOME"/*-scratch*) echo "$pid $comm $cwd"; n=$((n+1));; esac
done
[ $n -eq 0 ] && echo CLEAR
@@ -0,0 +1,21 @@
ffdd34ce118c6a13ffc3c0d7f47da3edab4518dbd8c31ae7a2ddd0f562aa7fc5 docs/prd/mosaic-stack.md
adfdad7370a5cd5841aca31991a15fba57375cc25c99636ebbf9ead16e42334f packages/bus/tests/channel-proof.test.mjs
6a697bb737feeab37fd6173fb7d0f1e83e5992d1102dc0cd275d4538d1891dda packages/cli/src/cli.mjs
9c28dc7b721a6044765b853a02f054ab484bdda0f62a0ffee65406919865bb33 packages/cli/tests/channel-cli.test.mjs
b68c983844e33ca1e8dddc761cf71b0b4d9cf8b167bd85f9a466a2c9e4cacec7 packages/cli/tests/channel-fixture.mjs
949ce374e4cd8248de63e9fdc059155f1857da07c8e603bb1b8a6974229a3545 packages/cli/tests/channel-recovery.test.mjs
58a382501319a413f80b6e6a2b7a01e7edab58fd53c4f59cf98050ce90010463 packages/cli/tests/project.test.mjs
3757e7b957b684409bd4727f9c149e8a13b2d5f450469bcffcc30dc04fdf9d5d packages/cli/tests/thread-cli.test.mjs
3fd676ae2a3c2ba71fefd4647f23598c360ad1b0b20a70dd25a13f0f9cddb228 packages/plugins/README.md
991597af202199aa83d6f3f82983610d7bd415eec49efb1e0a4645b4edaf7da2 packages/plugins/channel/cli/index.mjs
35dbba5419c36cf1e69c9edd04bb319243aaa8045d8e2b4b091d275fcdf58f1d packages/plugins/channel/webui/index.mjs
5fcecf8336ee247e8ac2f626b21e4c79b1edc49082eadfd536cd23016c6bb756 packages/plugins/src/channel.mjs
5b38f6192ca644ed4b24fb7c6bbe9dc9fc22de8b2def6c16c9b5e5eb945e2fb2 packages/plugins/src/kinds.mjs
2e99f9222424cf9503253701568f0bb5ef579a22930f9f224e5b63ad6b4f745f packages/plugins/src/loader.mjs
02349c1b7f4350fbaddb550baae9c09f5fe9828cd30bdaa158c2c54fb5cfa531 packages/plugins/tests/channel.test.mjs
8e8b49881475556862d51c95f917584a1af9f8b079406046dc0a6a8d09c14dfd packages/plugins/tests/kinds.test.mjs
05ef08af3295f0e5704356ff68f990e015161e96e9d8422a81b7af360fe5d225 packages/plugins/tests/loader.test.mjs
f9a88630b7eb491c54343264ce9723ada6bcf0039e8cf274c329567bc1e63ad9 packages/webui/src/chat-http.mjs
2034472d40e9dac9df7594563d5b7bd508fdf865f283ca9161f4ec9ca2fe7811 packages/webui/src/chat.mjs
ad02d1c4f28fd0c63f63b8e98aca70c4a177d9feaf7003c0551512b8b48c5fa4 packages/webui/tests/channel-chat.test.mjs
6f306cb2f68005303453685b8fd17bc8abb72bbf9ca5a62c57defd310f1a0689 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,21 @@
docs/prd/mosaic-stack.md
packages/bus/tests/channel-proof.test.mjs
packages/cli/src/cli.mjs
packages/cli/tests/channel-cli.test.mjs
packages/cli/tests/channel-fixture.mjs
packages/cli/tests/channel-recovery.test.mjs
packages/cli/tests/project.test.mjs
packages/cli/tests/thread-cli.test.mjs
packages/plugins/README.md
packages/plugins/channel/cli/index.mjs
packages/plugins/channel/webui/index.mjs
packages/plugins/src/channel.mjs
packages/plugins/src/kinds.mjs
packages/plugins/src/loader.mjs
packages/plugins/tests/channel.test.mjs
packages/plugins/tests/kinds.test.mjs
packages/plugins/tests/loader.test.mjs
packages/webui/src/chat-http.mjs
packages/webui/src/chat.mjs
packages/webui/tests/channel-chat.test.mjs
packages/webui/tests/chat.test.mjs
@@ -0,0 +1,10 @@
✔ CLI off, absent and other-only channel selections refuse every project/thread flow before any effect (13.815134ms)
✔ CLI enabled project/thread lists dispatch through selection without changing output (1.797278ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.438727
@@ -0,0 +1,10 @@
[
{
"check": "D1-business-selector-recovery",
"path": "packages/cli/src/cli.mjs",
"old": " message += ` Pass --business ${JSON.stringify(parsed.values[\"--business\"])} to both commands.`;\n",
"new": "",
"test": "packages/cli/tests/channel-cli.test.mjs",
"killer": "CLI off, absent and other-only channel selections refuse every project/thread flow before any effect"
}
]
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# Running cases requires Sage's separate full test grant. --preflight is read-only.
import json, os, pathlib, signal, subprocess, sys, re
ROOT=pathlib.Path('/mnt/storage/src/mosaic-stack-rocko-164-r1')
OUT=pathlib.Path(__file__).resolve().parent/'mutants'
cases=json.loads((OUT.parent/'mutant-sites.json').read_text())
for c in cases:
assert (ROOT / c['path']).read_text().count(c['old']) == 1, c['check']
if '--preflight' in sys.argv:
print('PREFLIGHT_OK', len(cases)); sys.exit(0)
OUT.mkdir(exist_ok=True)
resume = next((int(a.split('=')[1]) for a in sys.argv if a.startswith('--resume=')), 1)
rows = json.loads((OUT / 'results.json').read_text())[:resume - 1] if resume > 1 else []
def stop_tree(pid):
# Git's own runner creates process groups too. Capture descendants before killing the parent.
descendants = {pid}
for _ in range(8):
before = set(descendants)
for entry in pathlib.Path('/proc').iterdir():
if not entry.name.isdigit(): continue
try:
fields = (entry / 'stat').read_text().split(') ', 1)[1].split()
if int(fields[1]) in descendants: descendants.add(int(entry.name))
except (OSError, ValueError, IndexError): pass
if before == descendants: break
for child in descendants:
try: os.killpg(child, signal.SIGKILL)
except ProcessLookupError: pass
for c in cases[resume - 1:]:
file = ROOT / c['path']; original = file.read_bytes(); process = None
try:
file.write_text(original.decode().replace(c['old'], c['new']))
with (OUT / (c['check'] + '.log')).open('w') as log:
process = subprocess.Popen(['node', '--test', '--test-name-pattern', re.escape(c['killer']), c['test']], cwd=ROOT,
env={**os.environ, 'DOCKER_HOST': 'unix:///nonexistent.sock', 'TMPDIR': '/mnt/storage/scratch/rocko-164/tmp'}, stdout=log,
stderr=subprocess.STDOUT, start_new_session=True)
try: rc = process.wait(timeout=180)
except subprocess.TimeoutExpired: stop_tree(process.pid); process.wait(); rc = 'timeout'
content = (OUT / (c['check'] + '.log')).read_text()
checker = ['node', '--check', c['path']] if c['path'].endswith(('.mjs', '.js')) else ['bash', '-n', c['path']]
syntax = subprocess.run(checker, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True, timeout=10)
invalid = syntax.returncode != 0 or 'ERR_MODULE_NOT_FOUND' in content
verdict = 'INVALID' if invalid else 'TIMEOUT' if rc == 'timeout' else 'SURVIVED' if rc == 0 else 'KILLED'
rows.append({k: c[k] for k in ['check', 'path', 'test', 'killer']} | {'result': verdict, 'exit': rc})
(OUT / 'results.json').write_text(json.dumps(rows, indent=2) + '\n')
print(c['check'], verdict, rc, flush=True)
finally:
if process is not None and process.poll() is None: stop_tree(process.pid); process.wait()
file.write_bytes(original)
if verdict != 'KILLED': sys.exit(1)
@@ -0,0 +1 @@
D1-business-selector-recovery KILLED 1
@@ -0,0 +1,28 @@
✖ CLI off, absent and other-only channel selections refuse every project/thread flow before any effect (5.999463ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 118.947031
✖ failing tests:
test at packages/cli/tests/channel-cli.test.mjs:18:1
✖ CLI off, absent and other-only channel selections refuse every project/thread flow before any effect (5.999463ms)
AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value:
assert.ok(result.err.includes('Pass --business "demo" to both commands.'))
at TestContext.<anonymous> (file:///mnt/storage/src/mosaic-stack-rocko-164-r1/packages/cli/tests/channel-cli.test.mjs:27:14)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
@@ -0,0 +1,10 @@
[
{
"check": "D1-business-selector-recovery",
"path": "packages/cli/src/cli.mjs",
"test": "packages/cli/tests/channel-cli.test.mjs",
"killer": "CLI off, absent and other-only channel selections refuse every project/thread flow before any effect",
"result": "KILLED",
"exit": 1
}
]
@@ -0,0 +1,19 @@
eff12c55da1eeb1ba7ad7e44574332ecd8f385360c534f419932816db17e6759 BUILD.md
ef490735135a7847e79af95c7792cd69ada628d6368e98b3adc76ccc21a8f107 base.txt
aca0b24a79ff486fe9fa981be40e112474f92681910c0ea66f0664fc56b20af9 build.patch
ba45d57c9b255311bb220598f90c1a4fb01b420511974c28f102881cbd7df21a busy.log
7b91ce87aec0afe455049f8bcab8fd3581c5b4ed237ee7e503fc721080f877fc busy.sh
ae2fcfda96f5fdbc4fa580d1a6c8c793a8dde09890762e4a534b197d43f81fc8 candidate-manifest.sha256
8d9fab80a983852cedd1d663bc9d837098aed724989eb4fc4bf647c618fcb011 changed-paths.txt
9054d512f4e514cd1b40df1c58a4a200196440e16d85df84f4bdb3f8ebead82c channel-cli.log
ac5c211a0784a12a1e669b295d8db9dbe9341ab91a300655e10e7ccd15a11593 mutant-sites.json
f2e57784f42d20c63cf0395a30f4da08d4f05ab484e8e3759a29969c67390991 mutants/D1-business-selector-recovery.log
e06ae38b4565d820540975c095704748bab329001f6387b77e02d54db296928a mutants/results.json
67d228c9699dc88a7150b935f3f326502f6f9caadf905a33ed6cbd1f09a90302 mutants.py
6e80cd103fb4c7a140847abfe06918ea5143252a9eb238699d6d3d0eb3fec9b2 mutants.txt
28ee124e1db49b7d66bda03a10b3c8c413d7ad3478065b7aecc72dcc3b714683 r1-delta.patch
b7bf317d5f9d5e44e3f6ad10236ca6af7f7f1556135681b0887f342a8720f5f2 scan.json
59b8d54ec5118459a670c722fa6c574d21ee6853d7edece8e55fe9da3c3bedf3 scan.py
51df7cd61c62792f4c03b9357dd63c84885b42030fa5dc29dd2a7315563d8bce source-comparison.json
0ddd2ae8befa66ec25d71ed6f9a576853f9a3da41bb15ffc549ec97415011ebf start.txt
dd65a89d87f41c567b7a9d4620b47096206e49aa0f1018bb9ed9bbac1bdbb018 verification.json
@@ -0,0 +1,10 @@
--- r1/packages/cli/tests/channel-cli.test.mjs
+++ r2/packages/cli/tests/channel-cli.test.mjs
@@ -24,6 +24,7 @@
assert.equal(result.code, 3, argv.join(" "));
assert.match(result.err, /mosaic plugins.*mosaic plugins set/s);
assert.match(result.err, /plugins\.channel/);
+ assert.ok(result.err.includes('Pass --business "demo" to both commands.'));
assert.match(result.err, /cli.*webui/);
assert.equal(result.out, "");
assert.deepEqual(s.calls, [["plugins", {}]]);
+5
View File
@@ -0,0 +1,5 @@
{
"files": 18,
"findings": [],
"scope": "packet only: UTF-8/NUL, symlinks, private-key and credential-value patterns; no live reads"
}
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
import json, re, sys
out = Path(__file__).resolve().parent
findings = []
files = [p for p in out.rglob('*') if p.is_file() and p.name not in ('scan.json', 'packet-manifest.sha256')]
patterns = [r'-----BEGIN [A-Z ]*PRIVATE KEY-----', r'gh[pousr]_[A-Za-z0-9]{30,}', r'AKIA[0-9A-Z]{16}', r'(?i)(?:api_key|password|access_token)\s*[:=]\s*[\"\'][A-Za-z0-9_+/=-]{24,}']
for p in files:
if p.is_symlink(): findings.append({'path':str(p.relative_to(out)), 'kind':'symlink'}); continue
raw = p.read_bytes()
try: text = raw.decode('utf8')
except UnicodeDecodeError: findings.append({'path':str(p.relative_to(out)), 'kind':'non-UTF8'}); continue
if b'\x00' in raw: findings.append({'path':str(p.relative_to(out)), 'kind':'NUL'})
for pattern in patterns:
if re.search(pattern,text): findings.append({'path':str(p.relative_to(out)), 'kind':'credential-pattern'})
result = {'files':len(files), 'findings':findings, 'scope':'packet only: UTF-8/NUL, symlinks, private-key and credential-value patterns; no live reads'}
(out/'scan.json').write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result)); sys.exit(bool(findings))
@@ -0,0 +1,7 @@
{
"changed_from_r1": [
"packages/cli/tests/channel-cli.test.mjs"
],
"unchanged_paths": 20,
"approved_fixture_hunks": "byte-identical to r1"
}
+1
View File
@@ -0,0 +1 @@
2026-10-18T19:03:30Z
@@ -0,0 +1,15 @@
{
"start": "2026-10-18T19:03:30Z",
"end": "2026-10-18T19:03:49Z",
"counts": {
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"mutants_killed": 1,
"candidate_and_staged_restored": 21,
"reconstructed_paths": 21,
"cwd_sweep": []
}