Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
64 lines
3.3 KiB
JavaScript
64 lines
3.3 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { SOURCES } from "../src/bundle.mjs";
|
|
import { scratch } from "./helpers.mjs";
|
|
|
|
function setup(t, harness = "claude-code") {
|
|
const dir = scratch(t);
|
|
const workspace = join(dir, "ws");
|
|
mkdirSync(workspace);
|
|
const policy = join(dir, "policy.json");
|
|
writeFileSync(policy, JSON.stringify({ harness, workspace, tools: ["read", "bash"], typed: ["send_message"] }));
|
|
return { dir, workspace, policy };
|
|
}
|
|
|
|
// Runs the gate the way the hook does, directly or through a command string.
|
|
const gate = (policy, event) => spawnSync(process.execPath, [SOURCES.claudeGate, policy], { input: JSON.stringify(event), encoding: "utf8" });
|
|
const hook = (command, event) => spawnSync("sh", ["-c", command], { input: JSON.stringify(event), encoding: "utf8" });
|
|
const event = (tool_name, tool_input = {}) => ({ hook_event_name: "PreToolUse", tool_name, tool_input });
|
|
|
|
test("allow exits 0, a deny exits 2 with the reason on stderr", (t) => {
|
|
const { workspace, policy } = setup(t);
|
|
assert.equal(gate(policy, event("Read", { file_path: join(workspace, "a") })).status, 0);
|
|
assert.equal(gate(policy, event("mcp__mosaic__send_message", { to: "pm", body: "x" })).status, 0);
|
|
const denied = gate(policy, event("Write", { file_path: join(workspace, "a"), content: "" }));
|
|
assert.equal(denied.status, 2);
|
|
assert.match(denied.stderr, /^mosaic gate: Write isn't in this session's policy/);
|
|
const outside = gate(policy, event("Read", { file_path: "/etc/passwd" }));
|
|
assert.equal(outside.status, 2);
|
|
assert.match(outside.stderr, /outside the workspace/);
|
|
});
|
|
|
|
test("a missing or wrong policy, or a bad event, exits 2", (t) => {
|
|
const { dir, policy } = setup(t);
|
|
assert.equal(gate(join(dir, "missing.json"), event("Read")).status, 2);
|
|
const pi = setup(t, "pi").policy;
|
|
const r = gate(pi, event("Read"));
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /not a claude-code policy/);
|
|
const bad = spawnSync(process.execPath, [SOURCES.claudeGate, policy], { input: "{", encoding: "utf8" });
|
|
assert.equal(bad.status, 2);
|
|
});
|
|
|
|
test("the bundle's wrapped command: a missing gate or node still blocks", (t) => {
|
|
const { workspace, policy } = setup(t);
|
|
const command = (node, script) => `timeout -k 2 10 '${node}' '${script}' '${policy}' || exit 2`;
|
|
const allow = event("Read", { file_path: join(workspace, "a") });
|
|
assert.equal(hook(command(process.execPath, SOURCES.claudeGate), allow).status, 0);
|
|
assert.equal(hook(command(process.execPath, join(workspace, "no-gate.mjs")), allow).status, 2);
|
|
assert.equal(hook(command(join(workspace, "no-node"), SOURCES.claudeGate), allow).status, 2);
|
|
// A gate that hangs is killed by timeout and blocks.
|
|
const slow = join(workspace, "slow.mjs");
|
|
writeFileSync(slow, "setTimeout(() => {}, 60000);\n");
|
|
const started = Date.now();
|
|
const r = hook(`timeout -k 1 1 '${process.execPath}' '${slow}' '${policy}' || exit 2`, allow);
|
|
assert.equal(r.status, 2);
|
|
assert.ok(Date.now() - started < 5000);
|
|
// A policy removed after the bundle was written blocks too.
|
|
rmSync(policy);
|
|
assert.equal(hook(command(process.execPath, SOURCES.claudeGate), allow).status, 2);
|
|
});
|