Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
338 lines
24 KiB
JavaScript
338 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, 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",
|
|
...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, []);
|
|
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, 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", "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, 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,
|
|
}), /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"],
|
|
}), (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)));
|
|
});
|