Conflict in packages/webui/src/serve.mjs imports: kept F2's THREAD_DEFINITION and onboarding imports on stage B's organization path. The F2 assistant test edit followed the move to packages/organization; onboarding-browser now imports the organization service directly.
178 lines
8.4 KiB
JavaScript
178 lines
8.4 KiB
JavaScript
// The assistant role (REQ-ASSIST-1, row 119). The role file is policy: it
|
|
// holds message.send and record.write within role, no credentials, and tools
|
|
// as read-only as the chief of staff's, plus the web grant (REQ-HARN-2, vikunja:19/295). A widened file fails here.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
|
|
import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
|
|
|
const APPROVED = Object.freeze({
|
|
tools: ["read", "grep", "find", "ls", "web"],
|
|
network: "api-only",
|
|
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "record.write"]), crossRole: Object.freeze([]) }),
|
|
credentials: Object.freeze([]),
|
|
// REQ-ASSIST-2: the user and the chief of staff, both ways. The broker enforces these lists (row 120).
|
|
messaging: Object.freeze({
|
|
sendTo: Object.freeze(["human", "chief-of-staff"]),
|
|
receiveFrom: Object.freeze(["human", "chief-of-staff"]),
|
|
}),
|
|
});
|
|
|
|
const INSTANCE = "assistant";
|
|
|
|
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 loadFixture(mutate = () => {}) {
|
|
const root = tmp();
|
|
const doc = businessDoc(root, "acme");
|
|
doc.roles[INSTANCE] = { definition: "assistant", holder: "mos" };
|
|
mutate(doc);
|
|
writeJson(join(root, "businesses", "acme.json"), doc);
|
|
return { root, business: loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }) };
|
|
}
|
|
|
|
function resolved({ root, business }) {
|
|
return resolveInstance({ system: systemFor(root), business, instance: INSTANCE });
|
|
}
|
|
|
|
test("the role file loads and resolves through loadBusiness", () => {
|
|
const role = loadRole(REPO_ROLES, "assistant");
|
|
assert.equal(role.roleVersion, 2);
|
|
assert.equal(role.name, "assistant");
|
|
assert.equal(role.contract, "assistant.md");
|
|
assert.equal(role.contractPath, join(REPO_ROLES, "assistant.md"));
|
|
|
|
const record = resolved(loadFixture());
|
|
assert.equal(record.definition, "assistant");
|
|
assert.equal(record.holder, "mos");
|
|
assert.equal(record.contract, join(REPO_ROLES, "assistant.md"));
|
|
assert.equal(record.launch, null);
|
|
assert.equal(record.tracker, null);
|
|
});
|
|
|
|
test("the shipped file is exactly the approved list", () => {
|
|
const raw = JSON.parse(readFileSync(join(REPO_ROLES, "assistant.json"), "utf8"));
|
|
const { title, ...rest } = raw;
|
|
assert.equal(typeof title, "string");
|
|
assert.deepEqual(rest, { roleVersion: 2, name: "assistant", contract: "assistant.md", ...APPROVED });
|
|
});
|
|
|
|
test("the assistant's authority is message.send and record.write within role", () => {
|
|
const role = loadRole(REPO_ROLES, "assistant");
|
|
assert.deepEqual(role.authority, { withinRole: ["message.send", "record.write"], crossRole: [] });
|
|
assert.deepEqual(role.credentials, []);
|
|
const record = resolved(loadFixture());
|
|
assert.deepEqual(record.limits.authority, { withinRole: ["message.send", "record.write"], crossRole: [] });
|
|
assert.deepEqual(record.credentials, {});
|
|
for (const action of ACTIONS) {
|
|
assert.equal(classify(record, action), ["message.send", "record.write"].includes(action) ? "within" : "gated", action);
|
|
}
|
|
for (const action of ["task.create", "task.assign", "role.launch"]) assert.equal(classify(record, action), "gated", action);
|
|
});
|
|
|
|
test("the assistant talks to the user and the chief of staff only, and no other shipped role is limited", () => {
|
|
const role = loadRole(REPO_ROLES, "assistant");
|
|
assert.deepEqual(role.messaging, { sendTo: ["human", "chief-of-staff"], receiveFrom: ["human", "chief-of-staff"] });
|
|
assert.ok(Object.isFrozen(role.messaging.sendTo));
|
|
// Limiting another role is a policy change of its own; the shipped chief of staff and PM stay open.
|
|
for (const name of ["chief-of-staff", "pm", "cto", "coder", "reviewer", "researcher"]) {
|
|
assert.equal(loadRole(REPO_ROLES, name).messaging, null, name);
|
|
}
|
|
});
|
|
|
|
test("a business grant through limits does not widen the role", () => {
|
|
const record = resolved(loadFixture((doc) => {
|
|
doc.roles[INSTANCE].vars = { "limits.authority": ["message.send", "task.create", "role.launch"] };
|
|
}));
|
|
assert.deepEqual(record.limits.authority, { withinRole: ["message.send"], crossRole: [] });
|
|
assert.equal(record.launch, null);
|
|
});
|
|
|
|
test("a launch block whose by is the assistant is refused", () => {
|
|
refuses(
|
|
() => loadFixture((doc) => {
|
|
doc.launch = { ...doc.launch, by: INSTANCE, instances: ["coder"] };
|
|
}),
|
|
/launch\.by names assistant, whose role assistant doesn't hold role\.launch within-role/,
|
|
);
|
|
});
|
|
|
|
test("the instance takes no credential references and no tracker", () => {
|
|
refuses(
|
|
() => loadFixture((doc) => {
|
|
doc.roles[INSTANCE].credentials = { gitea: { file: "/x/token", rotateBy: "2099-01-01" } };
|
|
}),
|
|
/must reference exactly the services its role definition needs \(none; got gitea\)/,
|
|
);
|
|
refuses(
|
|
() => loadFixture((doc) => {
|
|
doc.roles[INSTANCE].tracker = { bot: "bot-acme-assistant", botId: 7 };
|
|
}),
|
|
/has "tracker" but assistant uses no vikunja credential/,
|
|
);
|
|
});
|
|
|
|
// Row 133 (#1627): an arbiter messages the raiser when it closes a decision, so a role limited by
|
|
// messaging lists can't be one. The message names the kind, the instance and the definition.
|
|
test("an arbiter whose role definition declares messaging lists is refused, for each kind", () => {
|
|
for (const kind of ["delivery", "technical"]) {
|
|
refuses(
|
|
() => loadFixture((doc) => { doc.arbiters[kind] = INSTANCE; }),
|
|
new RegExp(`arbiters\\.${kind} names assistant, whose role definition assistant declares messaging lists`),
|
|
);
|
|
// An instance name that isn't the definition's: both are named.
|
|
refuses(
|
|
() => loadFixture((doc) => {
|
|
doc.roles.aide = { definition: "assistant" };
|
|
doc.arbiters[kind] = "aide";
|
|
}),
|
|
new RegExp(`arbiters\\.${kind} names aide, whose role definition assistant declares messaging lists`),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("an arbiter without messaging lists is accepted beside the limited assistant", () => {
|
|
assert.deepEqual(loadFixture().business.arbiters, { delivery: "pm", technical: "cto" });
|
|
const { business } = loadFixture((doc) => { doc.arbiters.delivery = "chief"; });
|
|
assert.deepEqual(business.arbiters, { delivery: "chief", technical: "cto" });
|
|
});
|
|
|
|
test("the contract passes work to the chief of staff and directs no one", () => {
|
|
const text = readFileSync(join(REPO_ROLES, "assistant.md"), "utf8");
|
|
assert.match(text, /chief of staff/);
|
|
assert.match(text, /don't direct\s+other agents/);
|
|
assert.match(text, /create or assign\s+tasks/);
|
|
assert.match(text, /launch\s+sessions/);
|
|
assert.match(text, /exact instance key in the generated `Configured coordinator`\s+line under `This session`/);
|
|
assert.match(text, /Use that key as send_message\.to for requests\s+and rulings/);
|
|
assert.doesNotMatch(text, /addressed to the `chief-of-staff` role/);
|
|
// Round 1 F1: rulings go to the chief of staff, never to the role they concern (REQ-ASSIST-1, REQ-ASSIST-2).
|
|
assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff,\s+naming\s+whose\s+ruling\s+it\s+is\s+and\s+which\s+role\s+it\s+concerns/);
|
|
assert.match(text, /Don't\s+send\s+it\s+to\s+that\s+role\s+yourself/);
|
|
assert.doesNotMatch(text, /to\s+the\s+role\s+it\s+concerns/);
|
|
// F2: the prose matches the record.write grant (write_record), and spec/ stays human-only.
|
|
assert.doesNotMatch(text, /You have no write tool/);
|
|
assert.match(text, /write_record commits a project document/);
|
|
assert.match(text, /never writes `spec\/`/);
|
|
});
|
|
|
|
// Row 213 (C2-08, Case 2 design 4): companion and research hold it too, for the broker's web tools.
|
|
test("REQ-HARN-2: the assistant, companion and research hold the web grant, and no other role file does", () => {
|
|
const names = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && f !== "conductor-policy.json").map((f) => f.slice(0, -5));
|
|
const granted = names.filter((name) => loadRole(REPO_ROLES, name).tools.includes("web"));
|
|
assert.deepEqual(granted, ["assistant", "companion", "research"]);
|
|
for (const name of ["coder", "reviewer", "chief-of-staff", "pm", "cto", "researcher"]) {
|
|
assert.ok(!loadRole(REPO_ROLES, name).tools.includes("web"), name);
|
|
}
|
|
});
|