roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"] to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the writer check before it looks at authority, so the grant could never be used. A coder's task.reassign now classifies as gated and goes to the human. Backed by lead decision 90. Tests: business role and resolve (the narrowing test moves to the pm, which still holds two cross-role actions), a bus test on the shipped role files, a harness test that a coder with a tracker gets no task_reassign, the tasks fixture, and a verbs line showing a coder's task.reassign refuses field-writer with no action.allowed. mutants.sh is Rocko's packet rerun script, already committed. Author: Rocko (packet agents/rocko/work/queue-73/, commit2137dd7f). Candidate manifest sha256 bf4e5ed6, base324c064b, no drift on the candidate paths through8a42e819. Approval: Dewey, round 1, comment 27506 on #1567. Gate (Sage, /tmp/sage-g73 on8a42e819, 23:42-23:48Z): tasks 71, bus 81, business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed; every scripts/test-*.sh exit 0, 0 failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
197 lines
9.9 KiB
JavaScript
197 lines
9.9 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { join } from "node:path";
|
|
import {
|
|
BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument,
|
|
} from "../src/index.mjs";
|
|
import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
|
|
|
|
function refuses(fn, pattern) {
|
|
assert.throws(fn, (error) => {
|
|
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
|
assert.equal(error.exitCode, 2);
|
|
assert.match(error.message, pattern);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function setup(mutate, projectDoc) {
|
|
const root = tmp();
|
|
const doc = businessDoc(root);
|
|
mutate?.(doc);
|
|
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
|
const project = projectDoc
|
|
? validateProjectDocument({ projectVersion: 1, id: "stack", ...projectDoc }, projectFilePath(doc.projects.stack.root))
|
|
: null;
|
|
return { root, business, project, system: systemFor(root) };
|
|
}
|
|
|
|
const resolve = (s, instance) => resolveInstance({ system: s.system, business: s.business, project: s.project, instance });
|
|
|
|
test("systemVars flattens the validated config", () => {
|
|
const vars = systemVars({ environment: "development", dataRoot: "/d", execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" } });
|
|
assert.deepEqual({ ...vars }, {
|
|
environment: "development", dataRoot: "/d", "execution.backend": "docker",
|
|
"execution.provider": "zai", "execution.model": "m", "execution.adapter": "pi",
|
|
});
|
|
refuses(() => systemVars({ environment: "development", dataRoot: "relative", execution: { backend: "b", provider: "p", model: "m", adapter: "a" } }), /dataRoot must be an absolute path/);
|
|
});
|
|
|
|
test("precedence: system, business, project, project role, agent", () => {
|
|
const s = setup((d) => {
|
|
d.roles.coder.vars.model = "agent-model";
|
|
}, {
|
|
vars: { "tracker.pollSeconds": 20, "tracker.project": 3 },
|
|
roles: { coder: { vars: { "tracker.pollSeconds": 15 } } },
|
|
});
|
|
const coder = resolve(s, "coder");
|
|
assert.equal(coder.project, "stack");
|
|
assert.equal(coder.vars["tracker.pollSeconds"], 15);
|
|
assert.equal(coder.provenance["tracker.pollSeconds"], "project:stack:roles.coder");
|
|
assert.equal(coder.vars["tracker.project"], 3);
|
|
assert.equal(coder.provenance["tracker.project"], "project:stack");
|
|
assert.equal(coder.vars["tracker.baseUrl"], "http://127.0.0.1:3456");
|
|
assert.equal(coder.provenance["tracker.baseUrl"], "business:acme");
|
|
assert.equal(coder.vars.model, "agent-model");
|
|
assert.equal(coder.provenance.model, "business:acme:roles.coder");
|
|
assert.equal(coder.vars["execution.adapter"], "mock");
|
|
assert.equal(coder.provenance["execution.adapter"], "system");
|
|
assert.equal(coder.provenance["tracker.reconcileMinutes"], "default");
|
|
|
|
const pm = resolve(s, "pm");
|
|
assert.equal(pm.vars["tracker.pollSeconds"], 20);
|
|
assert.equal(pm.provenance["tracker.pollSeconds"], "project:stack");
|
|
const noProject = resolveInstance({ system: s.system, business: s.business, instance: "pm" });
|
|
assert.equal(noProject.project, null);
|
|
assert.equal(noProject.vars["tracker.pollSeconds"], 60);
|
|
});
|
|
|
|
test("limits narrow the definition and never widen it", () => {
|
|
const s = setup((d) => {
|
|
d.vars["limits.network"] = "open";
|
|
d.roles.cto.vars["limits.tools"] = ["read", "grep", "bash", "write"];
|
|
d.roles.reviewer.vars = { "limits.tools": ["read", "write"], "limits.authority": ["review.verdict", "task.close"] };
|
|
}, { roles: { cto: { vars: { "limits.tools": ["read", "bash", "write", "edit"] } } } });
|
|
const coder = resolve(s, "coder");
|
|
assert.equal(coder.limits.network, "none");
|
|
assert.deepEqual(coder.provenance["limits.network"], ["business:acme", "business:acme:roles.coder"]);
|
|
assert.equal(resolve(s, "pm").limits.network, "api-only");
|
|
|
|
const cto = resolve(s, "cto");
|
|
assert.deepEqual(cto.limits.tools, ["read", "write", "bash"]);
|
|
assert.deepEqual(cto.provenance["limits.tools"], ["project:stack:roles.cto", "business:acme:roles.cto"]);
|
|
|
|
const reviewer = resolve(s, "reviewer");
|
|
assert.deepEqual(reviewer.limits.tools, ["read"]);
|
|
assert.deepEqual(reviewer.limits.authority, { withinRole: ["review.verdict"], crossRole: [] });
|
|
assert.equal(classify(reviewer, "message.send"), "gated");
|
|
assert.equal(classify(reviewer, "task.close"), "gated");
|
|
});
|
|
|
|
test("role.launch stays within-role only for the instance the launch block names", () => {
|
|
const pm = resolve(setup(), "pm");
|
|
assert.equal(classify(pm, "role.launch"), "within");
|
|
assert.deepEqual(pm.launch.instances, ["coder", "reviewer"]);
|
|
assert.equal(resolve(setup(), "cto").launch, null);
|
|
|
|
const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm");
|
|
assert.equal(classify(noLaunch, "role.launch"), "gated");
|
|
assert.equal(noLaunch.launch, null);
|
|
|
|
// A second pm instance is the launcher, so the first one may not launch.
|
|
const two = setup((d) => {
|
|
d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } };
|
|
d.launch.by = "pm2";
|
|
});
|
|
assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated");
|
|
assert.equal(resolve(two, "pm").launch, null);
|
|
assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within");
|
|
assert.equal(resolve(two, "pm2").launch.by, "pm2");
|
|
});
|
|
|
|
test("limits.authority without role.launch leaves the launcher with no launch block", () => {
|
|
for (const layer of ["agent", "project"]) {
|
|
const authority = ["task.create", "task.assign", "message.send"];
|
|
const s = layer === "agent"
|
|
? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; })
|
|
: setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } });
|
|
const pm = resolve(s, "pm");
|
|
assert.equal(classify(pm, "role.launch"), "gated", layer);
|
|
assert.equal(pm.launch, null, layer);
|
|
}
|
|
});
|
|
|
|
test("limits.authority narrows cross-role actions too", () => {
|
|
// pm, the role with two cross-role actions: the limit keeps one and drops the other.
|
|
const s = setup((d) => {
|
|
d.roles.pm.vars["limits.authority"] = ["task.create", "task.close", "task.priority.change"];
|
|
});
|
|
const pm = resolve(s, "pm");
|
|
assert.deepEqual(pm.limits.authority, { withinRole: ["task.create", "task.close"], crossRole: ["task.priority.change"] });
|
|
assert.equal(classify(pm, "task.priority.change"), "cross");
|
|
assert.equal(classify(pm, "task.scope.change"), "gated");
|
|
assert.equal(classify(pm, "task.assign"), "gated");
|
|
});
|
|
|
|
test("classify", () => {
|
|
const s = setup();
|
|
const coder = resolve(s, "coder");
|
|
assert.equal(classify(coder, "git.push.working"), "within");
|
|
assert.equal(classify(coder, "task.scope.change"), "cross");
|
|
assert.equal(classify(coder, "task.reassign"), "gated");
|
|
assert.equal(classify(coder, "task.close"), "gated");
|
|
assert.equal(classify(coder, "deploy"), "gated");
|
|
assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross");
|
|
assert.equal(classify(resolve(s, "cto"), "decision.resolve.technical"), "within");
|
|
refuses(() => classify(coder, "task.delete"), /unknown action/);
|
|
});
|
|
|
|
test("the record carries what the broker and launcher need", () => {
|
|
const s = setup();
|
|
const pm = resolve(s, "pm");
|
|
assert.deepEqual(Object.keys(pm).sort(), [
|
|
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "tracker", "vars",
|
|
]);
|
|
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
|
|
assert.equal(pm.holder, "sage");
|
|
assert.deepEqual(pm.tracker, { bot: "bot-acme-pm", botId: 3 });
|
|
assert.deepEqual(Object.keys(pm.credentials).sort(), ["gitea", "vikunja"]);
|
|
assert.match(pm.credentials.gitea.file, /pm-gitea\.token$/);
|
|
assert.ok(Object.isFrozen(pm.limits.authority.withinRole));
|
|
});
|
|
|
|
test("digest: key order doesn't matter, any value change does", () => {
|
|
const root = tmp();
|
|
const file = join(root, "businesses", "acme.json");
|
|
const load = (mutate) => {
|
|
const doc = businessDoc(root);
|
|
mutate?.(doc);
|
|
return validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
|
|
};
|
|
const system = systemFor(root);
|
|
const digest = (business, instance = "coder", sys = system) => resolveInstance({ system: sys, business, instance }).digest;
|
|
const base = digest(load());
|
|
assert.match(base, /^[0-9a-f]{64}$/);
|
|
assert.equal(digest(load()), base);
|
|
assert.equal(digest(load((d) => { d.roles.coder.vars = { "limits.network": "none", harness: "pi" }; })), base);
|
|
assert.equal(digest(load((d) => { d.vars = Object.fromEntries(Object.entries(d.vars).reverse()); })), base);
|
|
assert.notEqual(digest(load((d) => { d.roles.coder.vars.harness = "claude-code"; })), base);
|
|
assert.notEqual(digest(load((d) => { d.roles.coder.holder = "rocko"; })), base);
|
|
assert.notEqual(digest(load((d) => { d.roles.coder.credentials.gitea.rotateBy = "2098-01-01"; })), base);
|
|
assert.notEqual(digest(load(), "coder", { ...system, "execution.model": "other" }), base);
|
|
assert.notEqual(digest(load(), "reviewer"), base);
|
|
});
|
|
|
|
test("refusals", () => {
|
|
const s = setup();
|
|
refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "ghost" }), /declares no role instance "ghost"/);
|
|
refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "constructor" }), /declares no role instance/);
|
|
const misnamed = setup(undefined, {});
|
|
const wrongId = validateProjectDocument({ projectVersion: 1, id: "other" }, misnamed.project.file);
|
|
refuses(() => resolveInstance({ system: misnamed.system, business: misnamed.business, project: wrongId, instance: "pm" }), /isn't declared under that id/);
|
|
const elsewhere = validateProjectDocument({ projectVersion: 1, id: "stack" }, "/elsewhere/.mosaic/project.json");
|
|
refuses(() => resolveInstance({ system: s.system, business: s.business, project: elsewhere, instance: "pm" }), /isn't declared under that id/);
|
|
const extraRole = setup(undefined, { roles: { ghost: { vars: {} } } });
|
|
refuses(() => resolve(extraRole, "pm"), /sets vars for role instance ghost/);
|
|
});
|