The CLI agent verbs validate the instance config through loadSystem before any read or write, and print a fixed line for errors they do not own. The root writer refuses a config directory with an ancestor another user can write without the sticky bit. The project HTTP route checks agent names with the organization ID_PATTERN and answers 400 naming the key. The Settings level forms take their own class, so the role-instance handlers no longer catch them, and keep both drafts in module state with the revision each began at. The organization writer tests now reach the file's own uid check and test the assistant protection with an assistant in the fixture.
367 lines
24 KiB
JavaScript
367 lines
24 KiB
JavaScript
// #1873 Phase A slice A2: the root agent file's writer and the project-agent writer verb (design
|
|
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { loadRootAgents } from "../src/agent-levels.mjs";
|
|
import {
|
|
ABSENT_REVISION, ROOT_LOCK, ROOT_RECEIPTS, changeRootAgent, patchRootEntry, projectAgentProblem, readRootAgentsFile, rootAgentSummary,
|
|
} from "../src/agent-levels-write.mjs";
|
|
import * as oldPath from "../../business/src/agent-levels-write.mjs";
|
|
import { organizationWriter } from "../src/organization.mjs";
|
|
import { businessDoc, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
|
|
|
const rolesDir = REPO_ROLES;
|
|
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
|
const OWNER = Object.freeze({ provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" });
|
|
const as = (actor = OWNER) => ({ actor, owner: OWNER, rolesDir });
|
|
|
|
function config() {
|
|
const dir = join(tmp(), "config");
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
chmodSync(dir, 0o700);
|
|
return dir;
|
|
}
|
|
const file = dir => join(dir, "agents.json");
|
|
const bytes = dir => existsSync(file(dir)) ? readFileSync(file(dir), "utf8") : null;
|
|
const receipts = dir => existsSync(join(dir, ROOT_RECEIPTS))
|
|
? readFileSync(join(dir, ROOT_RECEIPTS), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : [];
|
|
const seeded = async dir => {
|
|
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
|
|
return readRootAgentsFile(dir);
|
|
};
|
|
const refuses = (code, check = () => true) => error => error.code === code && check(error);
|
|
|
|
test("the old business path re-exports the writer", () => {
|
|
assert.equal(oldPath.changeRootAgent, changeRootAgent);
|
|
});
|
|
|
|
test("root writer: an owner write lands, reads back through A1's loader, and its receipt names the change", async () => {
|
|
const dir = config();
|
|
const receipt = await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
|
|
assert.equal(receipt.verb, "root.agent.set");
|
|
assert.equal(receipt.agent, "researcher");
|
|
assert.equal(receipt.definition, "research");
|
|
assert.deepEqual(receipt.changed, ["added"]);
|
|
assert.equal(receipt.revisionBefore, ABSENT_REVISION);
|
|
const st = lstatSync(file(dir));
|
|
assert.equal(st.mode & 0o777, 0o600);
|
|
assert.equal(st.nlink, 1);
|
|
const loaded = loadRootAgents(dir, { rolesDir });
|
|
assert.equal(loaded.agents.researcher.vars.model, "claude-opus-5-5");
|
|
const read = await readRootAgentsFile(dir);
|
|
assert.equal(read.revision, receipt.revisionAfter);
|
|
// A patch keeps the keys it leaves out and names, by key, what it changed.
|
|
const second = await changeRootAgent(dir, { revision: read.revision, name: "researcher", entry: old => patchRootEntry(old, { vars: { "limits.network": "api-only" }, excludeOrgs: ["home"] }) }, as());
|
|
assert.deepEqual(second.changed, ["vars.limits.network", "excludeOrgs"]);
|
|
assert.deepEqual(rootAgentSummary((await readRootAgentsFile(dir)).document), [{ name: "researcher", definition: "research", model: "claude-opus-5-5", varKeys: ["limits.network"], excludeOrgs: ["home"] }]);
|
|
const removed = await changeRootAgent(dir, { revision: second.revisionAfter, name: "researcher", entry: null }, as());
|
|
assert.equal(removed.verb, "root.agent.remove");
|
|
assert.deepEqual(removed.changed, ["removed"]);
|
|
assert.deepEqual(loadRootAgents(dir, { rolesDir }).agents, {});
|
|
const lines = receipts(dir);
|
|
assert.equal(lines.length, 3);
|
|
// Receipts carry names and key paths only: no var value, no host path.
|
|
const text = readFileSync(join(dir, ROOT_RECEIPTS), "utf8");
|
|
assert.ok(!text.includes("claude-opus-5-5") && !text.includes("api-only") && !text.includes(dir));
|
|
assert.ok(lines.every(line => line.actor === "owner" && line.uid === process.geteuid()));
|
|
assert.equal(lstatSync(join(dir, ROOT_RECEIPTS)).mode & 0o777, 0o600);
|
|
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
|
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
|
|
});
|
|
|
|
test("root writer: every actor but the owner refuses human-required and nothing is written", async () => {
|
|
const dir = config();
|
|
const others = [
|
|
{ provider: "local-dev", iss: "local-dev", sub: "chief" },
|
|
{ provider: "local-dev", iss: "local-dev", sub: "chief-of-staff.chief" },
|
|
{ provider: "local-dev", iss: "local-dev", sub: "tester" },
|
|
{ ...OWNER, provider: "tester" },
|
|
{ provider: "https-idp", iss: "https://idp.example", sub: "local-dev-owner" },
|
|
"local-dev-owner", "pm", null, undefined, {},
|
|
];
|
|
for (const actor of others) {
|
|
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor, owner: OWNER, rolesDir }), refuses("human-required"), JSON.stringify(actor ?? null));
|
|
}
|
|
// An owner context that names the tester is not an owner either.
|
|
const tester = { provider: "local-dev", iss: "local-dev", sub: "tester" };
|
|
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: tester, owner: tester, rolesDir }), refuses("human-required"));
|
|
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, rolesDir }), refuses("human-required"));
|
|
assert.equal(bytes(dir), null);
|
|
assert.deepEqual(receipts(dir), []);
|
|
});
|
|
|
|
test("root writer: a stale revision refuses and the file is unchanged", async () => {
|
|
const dir = config();
|
|
const first = await seeded(dir);
|
|
const before = bytes(dir);
|
|
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "reviewer", entry: { definition: "reviewer" } }, as()), refuses("revision-conflict"));
|
|
await changeRootAgent(dir, { revision: first.revision, name: "reviewer", entry: { definition: "reviewer" } }, as());
|
|
const after = bytes(dir);
|
|
// The first revision is now stale too.
|
|
await assert.rejects(changeRootAgent(dir, { revision: first.revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
|
|
assert.notEqual(before, after);
|
|
assert.equal(bytes(dir), after);
|
|
await assert.rejects(changeRootAgent(dir, { revision: "nope", name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
|
|
});
|
|
|
|
test("root writer: a symbolic link, a group-writable file and a foreign uid refuse and the file is unchanged", async () => {
|
|
const dir = config();
|
|
const { revision } = await seeded(dir);
|
|
const original = bytes(dir);
|
|
// A symbolic link in place of the file: the target is never followed or written.
|
|
const target = join(dir, "elsewhere.json");
|
|
writeFileSync(target, original); chmodSync(target, 0o600);
|
|
const real = join(dir, "real.json");
|
|
writeFileSync(real, original); chmodSync(real, 0o600);
|
|
const moved = readFileSync(file(dir));
|
|
writeFileSync(real, moved);
|
|
const { renameSync } = await import("node:fs");
|
|
renameSync(file(dir), join(dir, "kept.json"));
|
|
symlinkSync(target, file(dir));
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => !e.message.includes(dir)));
|
|
assert.equal(readFileSync(target, "utf8"), original);
|
|
assert.ok(lstatSync(file(dir)).isSymbolicLink());
|
|
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe"));
|
|
// Restore, then make it group-writable.
|
|
const { unlinkSync } = await import("node:fs");
|
|
unlinkSync(file(dir)); renameSync(join(dir, "kept.json"), file(dir));
|
|
chmodSync(file(dir), 0o620);
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /group or other/.test(e.message)));
|
|
assert.equal(bytes(dir), original);
|
|
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o620);
|
|
chmodSync(file(dir), 0o600);
|
|
// A second link to the file refuses: a write through one name would not be the file another reads.
|
|
const { linkSync } = await import("node:fs");
|
|
linkSync(file(dir), join(dir, "alias.json"));
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /one link/.test(e.message)));
|
|
unlinkSync(join(dir, "alias.json"));
|
|
// Not this process's uid: the directory checks refuse first (an ancestor this uid owns is foreign).
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
|
|
await assert.rejects(readRootAgentsFile(dir, { uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
|
|
assert.equal(bytes(dir), original);
|
|
// A file another uid owns, in a directory that is this uid's: the file's own owner check refuses.
|
|
// Changing a file's owner needs root, so the descriptor stat reports a foreign uid through the seam.
|
|
const foreign = async fd => { const st = await fd.stat(); return Object.assign(Object.create(Object.getPrototypeOf(st)), st, { uid: st.uid + 1 }); };
|
|
const receiptsBefore = existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null;
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
|
|
await assert.rejects(readRootAgentsFile(dir, { statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
|
|
assert.equal(bytes(dir), original);
|
|
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o600);
|
|
assert.equal(existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null, receiptsBefore);
|
|
assert.equal(existsSync(join(dir, ROOT_LOCK)), false, "the lock is released");
|
|
// A group-writable config directory refuses before any lock or read.
|
|
chmodSync(dir, 0o770);
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /config directory/.test(e.message)));
|
|
chmodSync(dir, 0o700);
|
|
// An ancestor another user can write without the sticky bit refuses: that user could rename the
|
|
// config directory between the checks and the write. With the sticky bit, as /tmp has, it can't.
|
|
const parent = dirname(dir);
|
|
const mode = lstatSync(parent).mode & 0o7777;
|
|
chmodSync(parent, 0o777);
|
|
try {
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /ancestor/.test(e.message) && !e.message.includes(dir)));
|
|
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe", e => /ancestor/.test(e.message)));
|
|
chmodSync(parent, 0o1777);
|
|
assert.equal((await readRootAgentsFile(dir)).revision, revision);
|
|
} finally { chmodSync(parent, mode); }
|
|
assert.equal(bytes(dir), original);
|
|
assert.equal(receipts(dir).length, 1);
|
|
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
|
});
|
|
|
|
test("root writer: an invalid document refuses with the key named and the value absent", async () => {
|
|
const dir = config();
|
|
const { revision } = await seeded(dir);
|
|
const original = bytes(dir);
|
|
const cases = [
|
|
[{ definition: "research", vars: { model: SECRET.repeat(10) } }, /variable model/],
|
|
[{ definition: "research", vars: { "limits.network": SECRET } }, /variable limits\.network/],
|
|
[{ definition: SECRET }, /root agents\.leaky\.definition/],
|
|
[{ definition: "research", excludeOrgs: [SECRET.toUpperCase()] }, /root agents\.leaky\.excludeOrgs/],
|
|
[{ definition: "research", vars: { [SECRET]: "x" } }, /root agents\.leaky\.vars/],
|
|
[{ definition: "research", credentials: { gitea: SECRET } }, /root agents\.leaky\.credentials/],
|
|
[{ definition: "chief-of-staff" }, /chief-of-staff can't be a root agent/],
|
|
[{ definition: "assistant" }, /assistant definition is the root agent named assistant only/],
|
|
];
|
|
for (const [entry, pattern] of cases) {
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "leaky", entry }, as()),
|
|
refuses("agents-invalid", e => pattern.test(e.message) && !e.message.includes(SECRET) && !e.message.includes(SECRET.toUpperCase()) && !e.message.includes(dir)),
|
|
JSON.stringify(Object.keys(entry)));
|
|
}
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: SECRET.toUpperCase(), entry: { definition: "research" } }, as()), refuses("agents-invalid", e => !e.message.includes(SECRET.toUpperCase())));
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "absent", entry: null }, as()), refuses("agent-not-found"));
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as()), refuses("no-change"));
|
|
assert.equal(bytes(dir), original);
|
|
assert.equal(receipts(dir).length, 1);
|
|
});
|
|
|
|
test("root writer: a crash between the temp file and the rename leaves the old file whole", async () => {
|
|
const dir = config();
|
|
const { revision } = await seeded(dir);
|
|
const original = bytes(dir);
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => { throw new Error("crash"); } }), /crash/);
|
|
assert.equal(bytes(dir), original);
|
|
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
|
|
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
|
assert.equal(receipts(dir).length, 1);
|
|
// A writer that ignores the lock and changes the file between the temp and the rename loses.
|
|
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => writeFileSync(file(dir), original.replace("research\"", "research\" ")) }), refuses("revision-conflict"));
|
|
// A new file is never published over one created meanwhile.
|
|
const fresh = config();
|
|
await assert.rejects(changeRootAgent(fresh, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), beforeReplace: () => writeJson(file(fresh), { agentsVersion: 1, agents: {} }) }), refuses("revision-conflict"));
|
|
assert.deepEqual(JSON.parse(bytes(fresh)), { agentsVersion: 1, agents: {} });
|
|
// A failure after the rename says the change landed.
|
|
const landed = config();
|
|
await assert.rejects(changeRootAgent(landed, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), syncDirectory: async () => { throw new Error("disk"); } }), e => e.committed === true);
|
|
assert.ok(loadRootAgents(landed, { rolesDir }).agents.pm);
|
|
});
|
|
|
|
test("root writer: a held lock refuses agents-locked", async () => {
|
|
const dir = config();
|
|
writeFileSync(join(dir, "agents.lock"), "");
|
|
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-locked"));
|
|
assert.equal(bytes(dir), null);
|
|
});
|
|
|
|
// The project-agent verb: the organization file's projects.<p>.agents, one entry per call.
|
|
function org(mutate = () => {}) {
|
|
const root = tmp();
|
|
const doc = businessDoc(root);
|
|
mutate(doc);
|
|
const dir = join(root, "config");
|
|
const orgFile = writeJson(join(dir, "businesses/acme.json"), doc);
|
|
chmodSync(dir, 0o700);
|
|
const roles = rolesCopy(root);
|
|
return { dir, orgFile, roles, writer: organizationWriter({ dir, rolesDir: roles }) };
|
|
}
|
|
const agentsOf = (writer, project = "stack") => writer.read("acme").document.projects[project].agents;
|
|
const history = (writer) => readFileSync(writer.logFile("acme"), "utf8");
|
|
|
|
test("project verb: each entry form lands, the log names paths only, and remove drops the key", () => {
|
|
const { writer } = org();
|
|
let { revision } = writer.read("acme");
|
|
const step = (name, entry) => ({ revision } = writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"));
|
|
step("checker", { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } });
|
|
assert.deepEqual(agentsOf(writer), { checker: { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } } });
|
|
step("coder", { vars: { "limits.network": "none" } });
|
|
step("reviewer", { exclude: true });
|
|
assert.deepEqual(Object.keys(agentsOf(writer)).sort(), ["checker", "coder", "reviewer"]);
|
|
step("checker", null);
|
|
step("coder", null);
|
|
step("reviewer", null);
|
|
assert.equal(writer.read("acme").document.projects.stack.agents, undefined);
|
|
const events = history(writer).trim().split("\n").map(line => JSON.parse(line)).filter(e => e.state === "committed");
|
|
assert.deepEqual(events.map(e => e.detail.form), ["define", "narrow", "exclude", "remove", "remove", "remove"]);
|
|
assert.ok(events.every(e => e.detail.verb === "project.agents.set" && e.detail.project === "stack" && e.actor === "jason"));
|
|
assert.ok(events.every(e => e.changes.every(c => !("old" in c) && !("new" in c))));
|
|
assert.ok(!history(writer).includes("claude-sonnet-5-5"));
|
|
});
|
|
|
|
test("project verb: a narrow that widens refuses, and refusals never quote a value", () => {
|
|
const { writer, orgFile } = org();
|
|
const { revision } = writer.read("acme");
|
|
const before = readFileSync(orgFile, "utf8");
|
|
const refusals = [
|
|
[{ vars: { model: SECRET } }, "project-agents-invalid", /can only narrow/],
|
|
[{ vars: { "limits.network": SECRET } }, "project-agents-invalid", /variable limits\.network/],
|
|
[{ definition: "fact-checker", vars: { model: SECRET.repeat(10) } }, "project-agents-invalid", /variable model/],
|
|
[{ definition: SECRET }, "project-agents-invalid", /projects\.stack\.agents\.coder\.definition/],
|
|
[{ definition: "fact-checker", credentials: { gitea: SECRET } }, "project-agents-invalid", /credentials/],
|
|
[{ exclude: false }, "project-agents-invalid", /exclude entry/],
|
|
[{}, "project-agents-invalid", /defines \(definition\), narrows \(vars\) or excludes/],
|
|
];
|
|
for (const [entry, code, pattern] of refusals) {
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "coder", entry }, "jason"),
|
|
e => e.code === code && pattern.test(e.message) && !e.message.includes(SECRET), JSON.stringify(entry));
|
|
}
|
|
// A narrow or exclude that names nothing it could act on refuses (the union check).
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: { exclude: true } }, "jason"), e => e.code === "project-agents-invalid" && /names no root or organization agent/.test(e.message));
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: null }, "jason"), { code: "agent-not-found" });
|
|
assert.equal(readFileSync(orgFile, "utf8"), before);
|
|
});
|
|
|
|
test("project verb: the chief and the assistant refuse", () => {
|
|
const { writer, orgFile } = org(doc => { doc.roles.assistant = { definition: "assistant" }; });
|
|
const { document, revision } = writer.read("acme");
|
|
assert.equal(document.roles.assistant.definition, "assistant", "the fixture has an organization assistant");
|
|
assert.equal(document.roles.chief.definition, "chief-of-staff", "the fixture has a chief");
|
|
const before = readFileSync(orgFile, "utf8");
|
|
const protectedAgent = /a project can't change the organization's (chief-of-staff|assistant)/;
|
|
const notProjectAgent = /can't be a project agent/;
|
|
for (const [name, entry, reason] of [
|
|
["chief", { exclude: true }, protectedAgent], ["chief", { vars: { "limits.network": "none" } }, protectedAgent], ["chief", { definition: "reviewer" }, protectedAgent],
|
|
["assistant", { exclude: true }, protectedAgent], ["assistant", { vars: { "limits.network": "none" } }, protectedAgent], ["assistant", { definition: "reviewer" }, protectedAgent],
|
|
["helper", { definition: "chief-of-staff" }, notProjectAgent], ["helper", { definition: "assistant" }, notProjectAgent],
|
|
]) {
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"), e => e.code === "project-agents-invalid" && reason.test(e.message), `${name} ${JSON.stringify(entry)}`);
|
|
}
|
|
assert.equal(readFileSync(orgFile, "utf8"), before);
|
|
});
|
|
|
|
test("project verb: every agent actor and every non-owner refuses human-required", () => {
|
|
const { writer, orgFile } = org();
|
|
const { revision } = writer.read("acme");
|
|
const before = readFileSync(orgFile, "utf8");
|
|
for (const actor of ["pm", "pm.pm", "chief", "chief-of-staff.chief", "coder", "tester", "local-dev-owner", "someone"]) {
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, actor), { code: "human-required" }, actor);
|
|
}
|
|
assert.equal(readFileSync(orgFile, "utf8"), before);
|
|
});
|
|
|
|
test("project verb: only projects.<p>.agents.<name> may change; the projects fence keeps every other writer out", () => {
|
|
const { writer, orgFile, roles } = org();
|
|
const { document, revision } = writer.read("acme");
|
|
const before = readFileSync(orgFile, "utf8");
|
|
// The general writer can't touch project agents, or anything else under projects.
|
|
const withAgents = structuredClone(document);
|
|
withAgents.projects.stack.agents = { checker: { definition: "fact-checker" } };
|
|
assert.throws(() => writer.write("acme", withAgents, { revision, actor: "jason" }), { code: "organization-projects-protected" });
|
|
// The verb's guard refuses any candidate that changes another projects key.
|
|
const root = { state: "absent", agents: {}, assistant: null };
|
|
const args = { orgId: "acme", project: "stack", name: "checker", entry: { definition: "fact-checker" }, rolesDir: roles, root };
|
|
const good = structuredClone(document);
|
|
good.projects.stack.agents = { checker: { definition: "fact-checker" } };
|
|
assert.equal(projectAgentProblem(document, good, args), null);
|
|
const mutations = [
|
|
d => { d.projects.stack.root = "/elsewhere"; },
|
|
d => { d.projects.other = { root: "/elsewhere" }; },
|
|
d => { d.projects.stack.agents.extra = { definition: "reviewer" }; },
|
|
d => { d.human = "someone"; },
|
|
d => { d.projects.stack.agents.checker = { definition: "reviewer" }; },
|
|
];
|
|
for (const mutate of mutations) {
|
|
const next = structuredClone(good);
|
|
mutate(next);
|
|
assert.equal(projectAgentProblem(document, next, args)?.[0], "organization-projects-protected", mutate.toString());
|
|
}
|
|
// A missing project and a library project refuse.
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "nope", name: "checker", entry: { definition: "fact-checker" } }, "jason"), { code: "project-not-found" });
|
|
const library = structuredClone(document);
|
|
library.projects.stack.kind = "library";
|
|
assert.equal(projectAgentProblem(library, good, args)?.[0], "project-agents-library");
|
|
// A stale revision refuses.
|
|
writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason");
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "other", entry: { definition: "reviewer" } }, "jason"), { code: "revision-conflict" });
|
|
assert.notEqual(readFileSync(orgFile, "utf8"), before);
|
|
});
|
|
|
|
test("project verb: a root file that refuses refuses the project write", () => {
|
|
const { writer, dir, orgFile } = org();
|
|
writeJson(join(dir, "agents.json"), { agentsVersion: 2 });
|
|
const { revision } = writer.read("acme");
|
|
const before = readFileSync(orgFile, "utf8");
|
|
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason"), e => e.code === "project-agents-invalid" && /root agent file/.test(e.message));
|
|
assert.equal(readFileSync(orgFile, "utf8"), before);
|
|
});
|
|
|
|
test("project verb: a narrow of a root agent checks against the root level", async () => {
|
|
const { writer, dir, roles } = org();
|
|
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, owner: OWNER, rolesDir: roles });
|
|
const { revision } = writer.read("acme");
|
|
writer.setProjectAgent("acme", { revision, project: "stack", name: "researcher", entry: { vars: { "limits.network": "none" } } }, "jason");
|
|
assert.deepEqual(agentsOf(writer).researcher, { vars: { "limits.network": "none" } });
|
|
});
|