Author and gate owner: Sage (mos-0855). Role files take an optional skills list. The bundle reads each named skill from <dataRoot>/skills-enabled, copies it into the bundle and renders every file into prompt.md between skill-file markers, so the prompt text matches the bundle copy byte for byte. The role's skills together are capped at SKILL_BYTES (64 KiB): the claude adapter passes prompt.md as one argument, and Linux caps one argument at 128 KiB. Symlinks are refused, not followed. The launcher checks the list before a run exists: skill-not-enabled names the activate command, anything else refuses skill-refused. Pi keeps --no-skills; Claude Code keeps --disable-slash-commands and --restricted. The two harness modules and MOSAIC_SKILLS are unchanged. First lists: coder, reviewer, cto and pm, ms-unslop on each.
341 lines
24 KiB
JavaScript
341 lines
24 KiB
JavaScript
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_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
|
import { decide } from "../src/gate.mjs";
|
|
|
|
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
const mode = (p) => statSync(p).mode & 0o777;
|
|
|
|
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, 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"]);
|
|
assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension));
|
|
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, 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.
|
|
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", "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, 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.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
|
|
});
|
|
|
|
// 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/<branch>.
|
|
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\. When a task needs a coder or reviewer session, request_staff names the task/);
|
|
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)));
|
|
});
|