import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs"; import { HARNESS, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs"; import { decide } from "../src/gate.mjs"; import { resolveInstance, validateBusinessDocument } from "../../organization/src/index.mjs"; import { businessDoc, REPO_ROLES, systemFor } from "../../organization/tests/helpers.mjs"; import { Store } from "../../bus/src/store.mjs"; import { Broker } from "../../bus/src/broker.mjs"; const UNSLOP_DIR = new URL("../../../tools/unslop-hook/", import.meta.url).pathname; const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex"); const mode = (p) => statSync(p).mode & 0o777; // #797 relay R2: no running chief or launchedBy hint is needed to discover // an arbitrary coordinator key. Exercise config -> resolve -> bundle -> bus. test("an owner-started assistant identifies and can address the configured offline coordinator", (t) => { const root = scratch(t); const doc = businessDoc(root); delete doc.roles.chief; doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "glm-5.3-flash", thinking: "high" } }; doc.roles.violet = { definition: "chief-of-staff", identity: { name: "Violet Dispatch", email: "violet@example.test", gitea: { url: "https://git.example", user: "violet" } } }; doc.roles.opal = { definition: "chief-information-officer" }; const records = [], prompts = []; for (const coordinator of ["violet", "opal"]) { const other = coordinator === "violet" ? "opal" : "violet"; doc.roles[coordinator].definition = "chief-of-staff"; doc.roles[other].definition = "chief-information-officer"; doc.launch.by = coordinator; const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); const resolve = (instance) => resolveInstance({ system: systemFor(root), business, instance }); const resolved = resolve("assistant"); const expectedName = coordinator === "violet" ? "Violet Dispatch" : null; assert.deepEqual(resolved.coordinator, { instance: coordinator, name: expectedName }); assert.ok(Object.isFrozen(resolved.coordinator)); assert.equal(resolved.launch, null, "routing context grants no launch authority"); records.push(resolved); const workspace = join(root, coordinator); mkdirSync(workspace); const bundle = buildBundle({ dir: join(workspace, "bundle"), resolved, contract: readFileSync(join(REPO_ROLES, "assistant.md"), "utf8"), harness: HARNESS.pi, session: { run: "assistant-run", launchedBy: business.human, workspace, toolSocket: join(workspace, "tools.sock"), turnMarker: join(workspace, "turn.done"), coordinator: { instance: other } } }); // Untrusted/session input cannot override the config. const prompt = readFileSync(bundle.files.prompt, "utf8"); prompts.push(prompt); assert.match(prompt, /Launched by: jason\./); assert.ok(prompt.includes(`Configured coordinator: instance key "${coordinator}"`)); assert.ok(!prompt.includes(`Configured coordinator: instance key "${other}"`)); if (expectedName) assert.ok(prompt.includes(`configured identity name: "${expectedName}"`)); assert.equal(bundle.manifest.resolvedDigest, resolved.digest); assert.equal(bundle.manifest.files["prompt.md"], sha(bundle.files.prompt)); const storeDir = join(workspace, "store"); mkdirSync(storeDir, { mode: 0o700 }); const store = new Store(storeDir); t.after(() => store.close()); const broker = new Broker({ store, businesses: { acme: { ...business, roles: Object.fromEntries(Object.entries(business.roles).map(([key, entry]) => [key, { ...entry, authority: resolve(key).limits.authority }])) } } }); const owner = broker.bindHuman({ business: "acme", human: business.human, via: "cli", outsideAgent: true }); const call = (cap, verb, args = {}) => broker.request(cap, { verb, args }); const project = call(owner, "project.create", { name: "Work" }).id; const thread = call(owner, "thread.create", { project, name: "Origin" }).id; const assistant = broker.bindLaunch({ business: "acme", role: "assistant", run: "assistant-run", harness: "pi", thread }); call(assistant, "role.claim"); assert.deepEqual(call(assistant, "agents"), [], "no coordinator or other non-thread role is running"); assert.equal(store.get("SELECT count(*) n FROM role_claims WHERE role=?", coordinator).n, 0, "the coordinator has never claimed a run"); const source = call(owner, "thread.send", { thread, body: "Build a timer" }); const sent = call(assistant, "message.send", { to: resolved.coordinator.instance, body: "Build a timer", in_reply_to: source.id }); assert.equal(store.get("SELECT to_role FROM messages WHERE id=?", sent.id).to_role, coordinator); assert.equal(sent.request, source.request); assert.throws(() => call(assistant, "message.send", { to: other, body: "Bypass", in_reply_to: source.id }), { code: "message-recipient-refused" }); assert.throws(() => call(assistant, "message.send", { to: "chief-of-staff", body: "Guess" }), { code: "unknown-role" }); } assert.notDeepEqual(records[0], records[1]); assert.notEqual(records[0].digest, records[1].digest); assert.notEqual(prompts[0], prompts[1]); }); test("an assistant without a configured launcher reports missing coordinator context", (t) => { const resolved = resolvedFor(t, "assistant", (doc) => { delete doc.launch; doc.roles.assistant = { definition: "assistant" }; }); assert.equal(resolved.coordinator, null); const b = build(t, resolved); assert.match(readFileSync(b.files.prompt, "utf8"), /Configured coordinator: none\. Report the missing business launcher configuration/); }); function build(t, resolved, extra = {}) { const dir = scratch(t); const workspace = join(dir, "ws"); mkdirSync(workspace); const bundle = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Contract\n\nDo the work.\n", tracker: true, credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }], session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") }, harness: HARNESS[sessionModel(resolved, null, extra.route ?? "primary").harness], harnessVersion: "fixture-version", skillsDir: REPO_SKILLS, ...extra, }); return { dir, workspace, ...bundle }; } // Row 65: a session's harness, model and thinking are its own three vars; the system's // execution.adapter and execution.model are never read, and a missing var refuses no-route by name. test("sessionModel: the route is the instance's own vars, and a missing one refuses no-route by name", (t) => { assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: null, model: "claude-opus-5-5", thinking: "high" }); const models = { "claude-opus-5-5": { provider: "anthropic", model: "claude-opus-5-5" } }; assert.deepEqual(sessionModel(resolvedFor(t, "pm"), models), { harness: "pi", provider: "anthropic", model: "claude-opus-5-5", modelKey: "claude-opus-5-5", thinking: "high" }); for (const missing of ["harness", "model", "thinking"]) { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high" }), { routes: false }); const without = { ...r, vars: Object.fromEntries(Object.entries(r.vars).filter(([k]) => k !== missing)) }; assert.throws(() => sessionModel(without), (e) => e instanceof BundleError && e.code === "no-route" && e.missing === missing && e.message.includes(`no ${missing} var`), missing); } // The reviewer sets no vars; the test system's execution.adapter and execution.model don't fill in. const reviewer = resolvedFor(t, "reviewer", null, { routes: false }); assert.throws(() => sessionModel(reviewer), (e) => e.code === "no-route" && e.missing === "harness"); // The fallback route reads only the fallback vars, and is no-route when they are unset. const fb = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "glm-5.3-flash", "fallback.thinking": "minimal" })); assert.deepEqual(sessionModel(fb, null, "fallback"), { harness: "pi", provider: null, model: "glm-5.3-flash", thinking: "minimal" }); assert.throws(() => sessionModel(resolvedFor(t, "pm"), null, "fallback"), (e) => e.code === "no-route" && e.missing === "fallback.harness"); assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection glm-5.3-flash/); }); test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => { const b = build(t, resolvedFor(t, "pm")); assert.equal(mode(join(b.dir, "bundle")), 0o700); for (const f of Object.values(b.files)) assert.equal(mode(f), 0o600, f); assert.deepEqual(Object.keys(b.files).sort(), ["manifest", "policy", "prompt", "tools"]); const prompt = readFileSync(b.files.prompt, "utf8"); assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/); assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./); assert.match(prompt, /Bus tools: send_message, request_staff, intake_status, intake_projects, intake_open, intake_question, intake_propose, deliver_request, advance_request, submit_prd, task_create, /); assert.doesNotMatch(prompt, /Bus tools: [^.]*\blaunch\b/); // Row 90 (#1584): PM fault 10 (#228 c91). assert.match(prompt, /^- Only the human stops a run, with `mosaic stop`\. If you think a run should stop, raise a role\.revoke decision with blocking false/m); assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")), { harness: "pi", workspace: b.workspace, tools: [...b.manifest.builtins], typed: b.tools.map((x) => x.name), network: "api-only", needs: ["gitea", "vikunja"], credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }], }); const m = b.manifest; assert.equal(m.harness, "pi"); assert.equal(m.model, "claude-opus-5-5"); assert.equal(m.thinking, "high"); assert.deepEqual([m.route, m.routeReason, m.harnessVersion], ["primary", null, "fixture-version"]); assert.equal(m.gateTimeout, null); assert.deepEqual(m.skills.map((x) => x.name), ["ms-unslop", "ms-goal", "ms-communications"]); assert.equal(m.reliesOn.length, 5); assert.ok(!m.typed.includes("launch"), "row 119: the PM no longer launches"); assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools", "unslopCheck", "unslopExtension", "unslopLists"]); assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension)); // Row 66 (#1559): the unslop check's files, as the session loads them. for (const [name, file] of [["unslopExtension", "extension.ts"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]]) assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name); for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name); assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m))); }); // Row 119 (#1613): the chief of staff's bundle carries the launch tool; its contract is the prompt's head. test("a chief-of-staff bundle lists launch among the bus tools and the manifest's typed tools", (t) => { const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" })); const b = build(t, r); const prompt = readFileSync(b.files.prompt, "utf8"); assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./); assert.match(prompt, /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /); assert.ok(b.manifest.typed.includes("launch")); assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []); }); // Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed. // Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing // the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3) // granted role.hire and role.fire, with hire and fire. test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => { for (const harness of ["pi", "claude-code"]) { const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" })); const b = build(t, r); const prefix = harness === "pi" ? "" : "mcp__mosaic__"; assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness); assert.deepEqual(b.manifest.untyped, [], harness); assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /); assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/); } }); // Row 132 (#1628): a role holding role.end is told about end_run; every other role keeps the human-only text. test("the stop line names end_run for a role.end holder, and every other role keeps today's text", (t) => { const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => /\brole\.revoke\b/.test(l) && /run/.test(l)); const human = "- Only the human stops a run, with `mosaic stop`. If you think a run should stop, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why."; const chief = line(build(t, resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" })))); assert.equal(chief.length, 1); assert.notEqual(chief[0], human); assert.match(chief[0], /^- You end another role's run with end_run, naming the role instance and a reason of 1 to 500 characters; the reason is required/); // Row 155 (#1649, Q169): the chief of staff ends the assistant's thread runs. assert.match(chief[0], /Ending the assistant ends each of its thread runs\. You can't end your own run\./); assert.doesNotMatch(chief[0], /assistant's/); assert.match(chief[0], /For anything the broker refuses, raise a role\.revoke decision with blocking false/); assert.doesNotMatch(chief[0], /Only the human stops a run/); for (const instance of ["pm", "coder"]) assert.deepEqual(line(build(t, resolvedFor(t, instance, (d) => (d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "claude-opus-5-5" })))), [human], instance); }); test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); const b = build(t, r); assert.deepEqual(Object.keys(b.files).sort(), ["manifest", "mcp", "policy", "prompt", "settings", "tools"]); const settings = JSON.parse(readFileSync(b.files.settings, "utf8")); const hook = settings.hooks.PreToolUse[0]; assert.equal(hook.matcher, "*"); assert.equal(hook.hooks[0].timeout, 20); assert.equal(hook.hooks[0].command, `timeout -k 2 10 '${process.execPath}' '${SOURCES.claudeGate}' '${b.files.policy}' || exit 2`); const mcp = JSON.parse(readFileSync(b.files.mcp, "utf8")); assert.deepEqual(mcp, { mcpServers: { mosaic: { type: "stdio", command: process.execPath, args: [SOURCES.mcpServer, b.files.tools, join(b.dir, "tools.sock")] } } }); const m = b.manifest; assert.equal(m.harnessVersion, "fixture-version"); assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 }); assert.ok(m.builtins.includes("Bash")); assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__"))); assert.equal(m.sources.claudeStream.path, join(REPO, "adapters/claude/stream.mjs")); assert.equal(m.sources.claudeStream.sha256, sha(m.sources.claudeStream.path)); assert.equal(m.sources.claudeStream.sha256, "5591c5808ced1c6e261408961a1ceed88c8c6e90099d81249622b5f3b9bbcc5b"); assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "claudeStream", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]); // Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block. for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) { assert.equal(settings.hooks[event].length, 1, event); assert.equal(settings.hooks[event][0].matcher, undefined, event); assert.deepEqual(settings.hooks[event][0].hooks, [{ type: "command", command: `timeout -k 2 10 '${process.execPath}' '${join(UNSLOP_DIR, "claude-hook.js")}' ${which}; exit 0`, timeout: 20 }], event); } for (const [name, file] of [["unslopHook", "claude-hook.js"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]]) assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name); }); // Row 89 (#1583): the prompt names the built-ins as the session's harness names them. test("the prompt's built-in tools line is the harness's own names, as the manifest lists them", (t) => { const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").find((l) => l.startsWith("- Built-in tools: ")); const pi = build(t, resolvedFor(t, "pm")); assert.equal(line(pi), "- Built-in tools: read, write, edit, grep, find, ls."); const claude = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness: "claude-code", model: "claude-opus-5-5" }))); assert.equal(line(claude), "- Built-in tools: Read, Write, Edit, Grep, Glob."); for (const b of [pi, claude]) assert.equal(line(b), `- Built-in tools: ${b.manifest.builtins.join(", ")}.`); for (const harness of ["pi", "claude-code"]) { const none = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness, model: "claude-opus-5-5", "limits.tools": [] }))); assert.equal(line(none), "- Built-in tools: none.", harness); } }); // Row 91 (#1585): the prompt names each project clone, and why a kept one wasn't updated. // Row 104 (#1598): and the commit each was at, when it refreshes, and the git config warning. // Row 117 (#1611): and a fetched clone's branch, reason and origin/. test("the prompt names the project clones in the workspace, and none when there are none", (t) => { const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => l.startsWith("- Project clones: ")); const plain = build(t, resolvedFor(t, "pm")); assert.deepEqual(line(plain), []); const dir = scratch(t); const session = (checkouts) => ({ run: "run-1", launchedBy: "pm", workspace: join(dir, "ws"), toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done"), checkouts }); const two = build(t, resolvedFor(t, "pm"), { session: session([ { project: "stack", path: join(dir, "ws", "stack"), outcome: "updated", branch: "main", commit: "a".repeat(40) }, { project: "site", path: join(dir, "ws", "site"), outcome: "kept", reason: "dirty" }, { project: "docs", path: join(dir, "ws", "docs"), outcome: "current", branch: "dev", commit: null }, { project: "lib", path: join(dir, "ws", "lib"), outcome: "fetched", reason: "dirty", branch: "main", commit: "b".repeat(40), origin: { branch: "main", commit: "c".repeat(40) } }, { project: "app", path: join(dir, "ws", "app"), outcome: "fetched", reason: "other-branch", branch: null, commit: "d".repeat(40), origin: { branch: "main", commit: null } }, ]) }); const after = "Each is a git clone of the project. At a launch the host fetches each clone it can safely run git in from its origin, and fast-forwards only a clean clone on the root's branch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that `git clone` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment."; assert.deepEqual(line(two), [`- Project clones: stack at ${join(dir, "ws", "stack")}, on branch main at commit ${"a".repeat(40)} when this session started; site at ${join(dir, "ws", "site")}, not updated at this launch (dirty); docs at ${join(dir, "ws", "docs")}, on branch dev at a commit the host couldn't read when this session started; lib at ${join(dir, "ws", "lib")}, on branch main at commit ${"b".repeat(40)} when this session started, fetched but not fast-forwarded at this launch (dirty), with origin/main at commit ${"c".repeat(40)}; app at ${join(dir, "ws", "app")}, on no branch at commit ${"d".repeat(40)} when this session started, fetched but not fast-forwarded at this launch (other-branch), with origin/main at a commit the host couldn't read. ${after}`]); const prompt = readFileSync(two.files.prompt, "utf8"); assert.ok(prompt.indexOf("- Workspace: ") < prompt.indexOf("- Project clones: ")); assert.deepEqual(line(build(t, resolvedFor(t, "pm"), { session: session([]) })), []); }); // Row 93 (#1587): the review loop's prompt lines, for the roles with its tools. test("the prompt explains the review loop to a role with push_working, and reviews to one with fetch_review", (t) => { const lines = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => /^- (Review loop|Reviews): /.test(l)); const dir = scratch(t); const session = { run: "run-1", launchedBy: "pm", workspace: join(dir, "ws"), toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done"), checkouts: [{ project: "stack", path: join(dir, "ws", "stack"), outcome: "cloned", branch: "main" }] }; const coder = lines(build(t, resolvedFor(t, "coder"), { session })); assert.equal(coder.length, 1); assert.match(coder[0], /^- Review loop: commit on a branch of your clone, push_working it, then request_review/); assert.match(coder[0], /closes only on an approve verdict for its latest push/); const reviewer = lines(build(t, resolvedFor(t, "reviewer", (d) => (d.roles.reviewer.vars = { harness: "pi" })), { session })); assert.equal(reviewer.length, 1); assert.match(reviewer[0], /^- Reviews: a REVIEW-REQUEST names a request id\. fetch_review it/); assert.deepEqual(lines(build(t, resolvedFor(t, "pm"), { session })), []); // Without a clone neither git tool exists, so neither line is there. assert.deepEqual(lines(build(t, resolvedFor(t, "coder"))), []); }); // Row 137 (#1631): the landing line comes with land_work, and only with it. test("the prompt explains landing to a role with land_work, and to no other", (t) => { const lines = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => /^- Landing: /.test(l)); const land = build(t, resolvedFor(t, "coder"), { land: true }); assert.deepEqual(lines(land), ["- Landing: once your merge decision is approved, land_work it with the task. The reply only says the landing started; the gate's outcome comes later as a RESULT from the bus. Don't land again while one is running."]); assert.ok(JSON.parse(readFileSync(land.files.tools, "utf8")).some((x) => x.name === "land_work")); assert.deepEqual(lines(build(t, resolvedFor(t, "coder"))), []); }); // Row 140 (#1634): the pm's prompt names request_staff; no other role's does. test("the prompt tells the pm to ask the chief of staff for sessions, and no other role", (t) => { const staff = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => /^- Staff: /.test(l)); const pm = staff(build(t, resolvedFor(t, "pm"))); assert.equal(pm.length, 1); assert.match(pm[0], /^- Staff: you don't launch sessions\. For intake or a task, request_staff takes exactly one of request or task_ref/); for (const instance of ["chief", "coder", "reviewer"]) assert.deepEqual(staff(build(t, resolvedFor(t, instance, (d) => (d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi" })))), [], instance); }); test("a bundle is written once: an existing file refuses", (t) => { const dir = scratch(t); mkdirSync(join(dir, "bundle")); writeFileSync(join(dir, "bundle", "prompt.md"), "old"); assert.throws(() => buildBundle({ dir: join(dir, "bundle"), resolved: resolvedFor(t, "pm"), contract: "c", session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") }, harness: HARNESS.pi, skillsDir: REPO_SKILLS, }), /EEXIST/); assert.equal(readFileSync(join(dir, "bundle", "prompt.md"), "utf8"), "old"); }); test("a path with a single quote can't go into the hook command", (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); const dir = scratch(t); assert.throws(() => buildBundle({ dir: join(dir, "it's"), resolved: r, contract: "c", session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") }, harness: HARNESS["claude-code"], skillsDir: REPO_SKILLS, }), (e) => e instanceof BundleError && /single quote/.test(e.message)); }); // REQ-HARN-2: the web grant. "web" in a role's tool limit gives web_search and web_fetch as typed // tools, on both harnesses; it is never a built-in, and the harness's own web tools stay off. const granted = (r) => ({ ...r, limits: { ...r.limits, tools: [...r.limits.tools, "web"] } }); const WEB = ["web_fetch", "web_search"]; for (const harness of ["pi", "claude-code"]) { test(`web grant on ${harness}: the bundle has web_search and web_fetch, and no built-in web tool`, (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness, model: "claude-sonnet-5-5" })); const b = build(t, granted(r)); const names = b.tools.map((x) => x.name); for (const n of WEB) assert.ok(names.includes(n), n); const web = b.tools.find((x) => x.name === "web_search"); assert.equal(web.verb, "web.search"); assert.equal(b.tools.find((x) => x.name === "web_fetch").verb, "web.fetch"); assert.deepEqual(b.policy.tools, r.limits.tools, '"web" is not a built-in'); assert.ok(!b.manifest.builtins.some((x) => /web/i.test(x)), "no built-in web tool"); assert.ok(b.manifest.typed.some((x) => x.endsWith("web_search"))); assert.match(readFileSync(b.files.prompt, "utf8"), /^- Web: web_search and web_fetch go through the broker/m); assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Built-in tools:[^\n]*web/i); if (harness === "claude-code") { const files = [b.files.settings, b.files.mcp, b.files.policy].map((f) => readFileSync(f, "utf8")).join("\n"); assert.doesNotMatch(files, /WebSearch|WebFetch/); } }); test(`no web grant on ${harness}: no web tool in the bundle, and the gate blocks a web call`, (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness, model: "claude-sonnet-5-5" })); const b = build(t, r); for (const n of WEB) assert.ok(!b.tools.some((x) => x.name === n), n); assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /web_search/); const call = harness === "pi" ? "web_search" : "mcp__mosaic__web_search"; assert.equal(decide(b.policy, call, { query: "x" }).allow, false); for (const builtin of ["WebSearch", "WebFetch"]) assert.equal(decide(b.policy, builtin, {}).allow, false); }); } test("a granted session's gate lets the typed web tools through and still blocks the built-ins", (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); const b = build(t, granted(r)); assert.equal(decide(b.policy, "mcp__mosaic__web_fetch", { url: "https://example.org/" }).allow, true); for (const builtin of ["WebSearch", "WebFetch"]) assert.equal(decide(b.policy, builtin, {}).allow, false); }); // REQ-HARN-2 REQ-PLUG-2 (Q162, decision 0b81b0c5): the three search-source modules, for a granted role and for roles without the grant. const withSource = (r, source) => ({ ...r, webSource: source ?? null }); const claudeCoder = (t) => resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); for (const source of ["searxng-url", "searxng-bundled"]) { test(`search-source ${source}: a granted role gets the typed broker tools and no harness web tool; an ungranted role gets nothing`, (t) => { for (const harness of ["pi", "claude-code"]) { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness, model: "claude-sonnet-5-5" })); const b = build(t, withSource(granted(r), source)); assert.ok(WEB.every((n) => b.tools.some((x) => x.name === n)), harness); assert.ok(!b.manifest.builtins.some((x) => /web/i.test(x)), harness); assert.equal(b.policy.webBuiltin, undefined); assert.equal(b.manifest.webSource, source); const n = build(t, withSource(r, source)); assert.ok(!n.tools.some((x) => WEB.includes(x.name)) && !n.manifest.builtins.some((x) => /web/i.test(x)), `${harness} ungranted`); assert.equal(n.manifest.webSource, null); } }); } test("search-source harness-builtin: claude-code gets WebSearch and WebFetch and no typed web tool, only when granted", (t) => { const r = claudeCoder(t); const b = build(t, withSource(granted(r), "harness-builtin")); assert.ok(b.manifest.builtins.includes("WebSearch") && b.manifest.builtins.includes("WebFetch")); assert.ok(!b.tools.some((x) => WEB.includes(x.name))); assert.equal(b.policy.webBuiltin, true); assert.equal(b.manifest.webSource, "harness-builtin"); assert.equal(decide(b.policy, "WebSearch", { query: "x" }).allow, true); assert.equal(decide(b.policy, "WebFetch", { url: "https://example.org/" }).allow, true); assert.match(readFileSync(b.files.prompt, "utf8"), /^- Web: WebSearch and WebFetch are the harness's own tools/m); // Roles without the grant keep them off, whatever the source (coder, reviewer and chief all lack it). for (const role of ["coder", "reviewer", "chief"]) { const rr = resolvedFor(t, role, (d) => (d.roles[role].vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); const nb = build(t, withSource(rr, "harness-builtin")); assert.ok(!nb.manifest.builtins.some((x) => /web/i.test(x)), role); assert.equal(decide(nb.policy, "WebSearch", {}).allow, false, role); assert.equal(decide(nb.policy, "WebFetch", {}).allow, false, role); assert.ok(!nb.tools.some((x) => WEB.includes(x.name)), role); } }); test("search-source harness-builtin is refused for a granted pi role, which has no built-in web tool", (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "claude-sonnet-5-5" })); assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on pi/.test(e.message)); // Without the grant the source doesn't matter on pi. assert.ok(!build(t, withSource(r, "harness-builtin")).manifest.builtins.some((x) => /web/i.test(x))); });