447 lines
32 KiB
JavaScript
447 lines
32 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, 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: "[email protected]", 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 for model var/);
|
|
});
|
|
|
|
for (const [route, name] of [["primary", "model"], ["fallback", "fallback.model"]]) test(`unknown ${route} model selection names the variable without its value`, (t) => {
|
|
const secret = "sk-test-NOT-A-SECRET-1234";
|
|
const resolved = resolvedFor(t, "coder");
|
|
const vars = { ...resolved.vars, [name]: secret, "fallback.harness": "pi", "fallback.thinking": "high" };
|
|
assert.throws(() => sessionModel({ ...resolved, vars }, {}, route), error => {
|
|
assert.ok(error instanceof BundleError);
|
|
assert.equal(error.code, "bundle-refused");
|
|
assert.equal(error.message, `instance coder: unknown model table selection for ${name} var`);
|
|
assert.ok(!error.message.includes(secret));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
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 held then was untyped. Row 194 (#1692, PRD 1.7 REQ-COS-3)
|
|
// granted role.hire and role.fire, with hire and fire. RR adds two resolve rights; TI owns their tools.
|
|
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and resolve rights as 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", "my_rails"].map((n) => prefix + n), harness);
|
|
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], 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.match(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/<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\. 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 the configured harness/.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)));
|
|
});
|