- A checkout an earlier launch left for a project the instance may no longer work in is covered by an empty tmpfs inside the sandbox; the host copy stays. - The launcher rechecks every prepared project against the reloaded document after preparation, and the bus's final admission takes the instance and checks the broker's current unions. - A project define whose vars changed since boot refuses its copy until the next start. - Instance-name collisions are recomputed at each reload from the frozen root claims and the current project defines; every claimant refuses.
330 lines
20 KiB
JavaScript
330 lines
20 KiB
JavaScript
// #1873 Phase A slice A3: root and project agent copies (design
|
|
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { checkRootAgents, loadRootAgents } from "../src/agent-levels.mjs";
|
|
import {
|
|
copiesView, copyInstanceName, copyRefusal, loadWithCopies, materializeCopies, projectRefusal, withCopies,
|
|
} from "../src/agent-copies.mjs";
|
|
import { businessFilePath, validateBusinessDocument } from "../src/organization-config.mjs";
|
|
import { resolveInstance } from "../src/resolve.mjs";
|
|
import { REPO, REPO_ROLES, businessDoc, systemFor, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
|
|
|
const rolesDir = REPO_ROLES;
|
|
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
|
const root = (agents) => ({ state: "ok", ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
|
|
|
|
// An organization `id` under `dir`, written where loadBusiness and the writer read it.
|
|
function org(dir, id, edit = (d) => d) {
|
|
const doc = edit(businessDoc(dir, id));
|
|
writeJson(businessFilePath(id, dir), doc);
|
|
return doc;
|
|
}
|
|
const boot = (dir, id, doc, r) => {
|
|
const snapshot = materializeCopies({ orgId: id, doc, root: r, rolesDir });
|
|
return { snapshot, business: withCopies(doc, businessFilePath(id, dir), { rolesDir, snapshot }) };
|
|
};
|
|
|
|
test("A3: two root agents, one excluded: the other becomes an instance in each organization, each its own", () => {
|
|
const dir = tmp();
|
|
const r = root({ gate: { definition: "merge-gate" }, sec: { definition: "security-reviewer", excludeOrgs: ["acme"] }, assistant: { definition: "assistant" } });
|
|
const acme = boot(dir, "acme", org(dir, "acme"), r);
|
|
assert.deepEqual(Object.keys(acme.snapshot.copies), ["gate"]);
|
|
assert.equal(acme.business.roles.gate.definition, "merge-gate");
|
|
assert.equal(Object.hasOwn(acme.business.roles, "sec"), false, "the excluded root agent is no instance");
|
|
// The root assistant stays shown, not launched (A6): never a copy.
|
|
assert.equal(Object.hasOwn(acme.business.roles, "assistant"), false);
|
|
assert.equal(acme.business.copies.gate.state, "instance");
|
|
// A second organization makes its own copies from the same root agents.
|
|
const beta = boot(dir, "beta", org(dir, "beta"), r);
|
|
assert.deepEqual(Object.keys(beta.snapshot.copies).sort(), ["gate", "sec"]);
|
|
assert.equal(beta.snapshot.org, "beta");
|
|
assert.notEqual(beta.snapshot.copies.gate, acme.snapshot.copies.gate);
|
|
assert.ok(Object.isFrozen(acme.snapshot.copies.gate), "the boot snapshot can't be changed in place");
|
|
// Each copy carries only its own organization's entry: the credential map is the organization's
|
|
// own, and a copy holds no credential.
|
|
assert.deepEqual(acme.business.roles.gate.credentials, {});
|
|
assert.deepEqual(beta.business.roles.gate.credentials, {});
|
|
});
|
|
|
|
test("A3: an unbound copy refuses by name and binding, and no value reaches the refusal or the view", () => {
|
|
const dir = tmp();
|
|
const doc = org(dir, "acme", (d) => {
|
|
// The secret is bound elsewhere in this organization: the coder's gitea token file is named it
|
|
// and holds it.
|
|
const file = tokenFile(join(dir, "secrets"), SECRET);
|
|
writeFileSync(file, SECRET);
|
|
d.roles.coder.credentials.gitea = { file, rotateBy: "2099-01-01" };
|
|
return d;
|
|
});
|
|
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
|
|
const { snapshot, business } = boot(dir, "acme", doc, r);
|
|
assert.equal(snapshot.copies.researcher.state, "unbound");
|
|
assert.deepEqual(snapshot.copies.researcher.missing, ["gitea"]);
|
|
assert.deepEqual(snapshot.copies.reviewer2.missing, ["gitea", "vikunja"]);
|
|
assert.equal(Object.hasOwn(business.roles, "researcher"), false, "an unbound copy is no instance");
|
|
const refusal = copyRefusal(business, "researcher");
|
|
assert.equal(refusal.code, "credential-unbound");
|
|
assert.match(refusal.message, /researcher/);
|
|
assert.match(refusal.message, /gitea/);
|
|
assert.equal(copyRefusal(business, "coder"), null, "an org instance is no copy");
|
|
for (const text of [refusal.message, copyRefusal(business, "reviewer2").message, JSON.stringify(business.copies), JSON.stringify(copiesView(snapshot.copies))]) {
|
|
assert.equal(text.includes(SECRET), false);
|
|
assert.equal(text.includes(dir), false, "no host path");
|
|
}
|
|
});
|
|
|
|
test("A3: a copy whose var the organization refuses is refused alone, and its value stays out of the reason", () => {
|
|
const dir = tmp();
|
|
const doc = org(dir, "acme");
|
|
const r = root({ gate: { definition: "merge-gate" }, wide: { definition: "merge-gate", vars: { model: SECRET } } });
|
|
const { business } = boot(dir, "acme", doc, r);
|
|
assert.equal(business.roles.gate.definition, "merge-gate", "the other copy still joins");
|
|
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
|
assert.equal(business.copies.wide.state, "refused");
|
|
const refusal = copyRefusal(business, "wide");
|
|
assert.equal(refusal.code, "copy-refused");
|
|
assert.match(refusal.message, /^wide can't launch in acme: /);
|
|
assert.equal(refusal.message.includes(SECRET), false);
|
|
assert.equal(JSON.stringify(business.copies).includes(SECRET), false);
|
|
});
|
|
|
|
test("A3: a copy's tools and actions equal an org instance's of the same role; a copy that skips checkRoles fails this", () => {
|
|
const dir = tmp();
|
|
const doc = org(dir, "acme", (d) => ({ ...d, roles: { ...d.roles, "gate-org": { definition: "merge-gate", vars: { model: "glm-5.3-flash" } } } }));
|
|
const r = root({ gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } });
|
|
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
|
|
const file = businessFilePath("acme", dir);
|
|
const system = systemFor(dir);
|
|
const same = (business) => {
|
|
assert.deepEqual(business.roles.gate, business.roles["gate-org"]);
|
|
const copy = resolveInstance({ system, business, instance: "gate" });
|
|
const own = resolveInstance({ system, business, instance: "gate-org" });
|
|
assert.deepEqual(copy.limits.tools, own.limits.tools);
|
|
assert.deepEqual(copy.limits.authority, own.limits.authority);
|
|
assert.deepEqual(copy.credentials, own.credentials);
|
|
// A copy the organization's own checks refuse never joins.
|
|
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
|
};
|
|
same(withCopies(doc, file, { rolesDir, snapshot }));
|
|
// The counterfactual: the copies joined as their raw entries, with no checkRoles.
|
|
const plain = validateBusinessDocument(doc, file, { rolesDir });
|
|
const skipped = { ...plain, roles: { ...plain.roles, gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } } };
|
|
assert.throws(() => same(skipped), assert.AssertionError);
|
|
});
|
|
|
|
test("A3: a root file change after boot changes nothing until the next boot", () => {
|
|
const dir = tmp();
|
|
const doc = org(dir, "acme");
|
|
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { gate: { definition: "merge-gate" } } });
|
|
const snapshot = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
|
const first = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
|
assert.equal(first.roles.gate.definition, "merge-gate");
|
|
// The root file now drops gate, adds one and excludes acme; the bus holds its snapshot.
|
|
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" }, gate: { definition: "merge-gate", excludeOrgs: ["acme"] } } });
|
|
const later = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
|
assert.deepEqual(Object.keys(later.copies), ["gate"]);
|
|
assert.equal(later.roles.gate.definition, "merge-gate");
|
|
assert.equal(Object.hasOwn(later.roles, "sec"), false);
|
|
// A broken root file is no different: nothing after boot reads it.
|
|
writeFileSync(join(dir, "agents.json"), "{ not json");
|
|
assert.equal(loadWithCopies("acme", { dir, rolesDir, snapshot }).roles.gate.definition, "merge-gate");
|
|
// The next boot reads it again.
|
|
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" } } });
|
|
const next = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
|
assert.deepEqual(Object.keys(next.copies), ["sec"]);
|
|
// Nothing that runs after boot reads the root file: only the cli's boot step names loadRootAgents.
|
|
for (const src of ["packages/bus/src/runtime.mjs", "packages/bus/src/broker.mjs", "packages/cli/src/launcher.mjs", "packages/organization/src/agent-copies.mjs"])
|
|
assert.equal(/loadRootAgents\s*\(/.test(readFileSync(join(REPO, src), "utf8")), false, src);
|
|
});
|
|
|
|
test("A3: project agents copy into their own project, and a refused union refuses every agent it defines", () => {
|
|
const dir = tmp();
|
|
mkdirSync(join(dir, "web"), { recursive: true });
|
|
const doc = org(dir, "acme", (d) => {
|
|
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
|
// web excludes an agent nothing defines, so its union refuses.
|
|
d.projects.web = { root: join(dir, "web"), agents: { checker: { definition: "merge-gate" }, ghost: { exclude: true } } };
|
|
return d;
|
|
});
|
|
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
|
assert.equal(copyInstanceName("checker", "stack"), "checker.stack");
|
|
assert.deepEqual({ ...snapshot.copies["checker.stack"], vars: undefined }, { name: "checker", level: "project", project: "stack", definition: "merge-gate", vars: undefined, state: "instance" });
|
|
assert.equal(business.roles["checker.stack"].definition, "merge-gate");
|
|
assert.equal(Object.hasOwn(business.roles, "checker.web"), false);
|
|
const refusal = copyRefusal(business, "checker.web");
|
|
assert.equal(refusal.code, "project-refused");
|
|
assert.match(refusal.message, /^checker\.web can't launch in acme: project web refuses its agents: /);
|
|
assert.match(refusal.message, /ghost/);
|
|
// A project that stops being declared after boot takes its copy with it.
|
|
const { stack, ...rest } = doc.projects;
|
|
const later = withCopies({ ...doc, projects: rest }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
|
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
|
assert.equal(copyRefusal(later, "checker.stack").code, "project-refused");
|
|
});
|
|
|
|
test("A3: an organization instance of the same name wins over a root or project copy, at boot and after it, and the copy is gone, not refused", () => {
|
|
const dir = tmp();
|
|
const doc = org(dir, "acme", (d) => {
|
|
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
|
// A raw org instance already named like the project copy wins at boot.
|
|
d.roles["checker.stack"] = { definition: "security-reviewer" };
|
|
return d;
|
|
});
|
|
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
|
assert.equal(business.copies.gate.state, "instance");
|
|
assert.equal(Object.hasOwn(snapshot.copies, "checker.stack"), false);
|
|
assert.equal(business.roles["checker.stack"].definition, "security-reviewer");
|
|
assert.equal(copyRefusal(business, "checker.stack"), null);
|
|
// An org instance added after boot replaces the root copy: the copy leaves the lookup, so nothing
|
|
// refuses the organization's own instance.
|
|
const later = withCopies({ ...doc, roles: { ...doc.roles, gate: { definition: "security-reviewer" } } }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
|
assert.equal(later.roles.gate.definition, "security-reviewer", "the organization file's own instance");
|
|
assert.equal(Object.hasOwn(later.copies, "gate"), false);
|
|
assert.equal(copyRefusal(later, "gate"), null);
|
|
assert.equal(projectRefusal(later, "gate", "stack"), null);
|
|
});
|
|
|
|
test("A3: two agents whose instance names come out the same both refuse, naming each; neither overwrites the other", () => {
|
|
const dir = tmp();
|
|
mkdirSync(join(dir, "a.b"), { recursive: true });
|
|
mkdirSync(join(dir, "b"), { recursive: true });
|
|
const doc = org(dir, "acme", (d) => {
|
|
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
|
// Project a.b's c and project b's c.a both come out c.a.b.
|
|
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
|
d.projects.b = { root: join(dir, "b"), agents: { "c.a": { definition: "merge-gate" } } };
|
|
return d;
|
|
});
|
|
// Root checker.stack is a different agent from project stack's checker.
|
|
const { snapshot, business } = boot(dir, "acme", doc, root({ "checker.stack": { definition: "security-reviewer" } }));
|
|
for (const [instance, claims] of [["checker.stack", /root agent checker\.stack and project stack's checker/], ["c.a.b", /project a\.b's c and project b's c\.a/]]) {
|
|
assert.equal(snapshot.copies[instance].state, "refused", instance);
|
|
assert.match(snapshot.copies[instance].reason, claims);
|
|
assert.equal(Object.hasOwn(business.roles, instance), false, instance);
|
|
assert.ok(copyRefusal(business, instance), instance);
|
|
}
|
|
// The view lists every claimant, so a reader finds each agent's copy by its name and project.
|
|
assert.deepEqual(business.copies["checker.stack"].claims, [{ name: "checker.stack", project: null }, { name: "checker", project: "stack" }]);
|
|
assert.deepEqual(business.copies["c.a.b"].claims, [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }]);
|
|
});
|
|
|
|
test("A3: a collision the current file brings in after boot refuses every claimant it reaches, and lifts when the file drops it", () => {
|
|
const dir = tmp();
|
|
mkdirSync(join(dir, "a.b"), { recursive: true });
|
|
mkdirSync(join(dir, "b"), { recursive: true });
|
|
const file = businessFilePath("acme", dir);
|
|
const doc = org(dir, "acme", (d) => {
|
|
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
|
d.projects.b = { root: join(dir, "b") };
|
|
return d;
|
|
});
|
|
const { snapshot, business } = boot(dir, "acme", doc, root({ "new.stack": { definition: "security-reviewer" } }));
|
|
assert.equal(business.copies["c.a.b"].state, "instance");
|
|
assert.equal(business.copies["new.stack"].state, "instance");
|
|
// Project b's c.a and project stack's new now come out as the names of copies made at boot.
|
|
const edited = structuredClone(doc);
|
|
edited.projects.b.agents = { "c.a": { definition: "merge-gate" } };
|
|
edited.projects.stack.agents = { new: { definition: "merge-gate" } };
|
|
const later = withCopies(edited, file, { rolesDir, snapshot });
|
|
for (const [instance, claims, reason] of [
|
|
["c.a.b", [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }], "the instance name c.a.b is claimed by project a.b's c and project b's c.a"],
|
|
["new.stack", [{ name: "new.stack", project: null }, { name: "new", project: "stack" }], "the instance name new.stack is claimed by root agent new.stack and project stack's new"],
|
|
]) {
|
|
assert.equal(later.copies[instance].state, "refused", instance);
|
|
assert.deepEqual(later.copies[instance].claims, claims, instance);
|
|
assert.equal(later.copies[instance].reason, reason, instance);
|
|
assert.equal(Object.hasOwn(later.roles, instance), false, instance);
|
|
assert.ok(copyRefusal(later, instance), instance);
|
|
}
|
|
// Without the new defines, both copies are instances again.
|
|
const back = withCopies(doc, file, { rolesDir, snapshot });
|
|
assert.equal(back.copies["c.a.b"].state, "instance");
|
|
assert.equal(back.copies["new.stack"].state, "instance");
|
|
});
|
|
|
|
test("A3: a project define whose vars change after boot refuses its copy until the next boot", () => {
|
|
const dir = tmp();
|
|
const file = businessFilePath("acme", dir);
|
|
const doc = org(dir, "acme", (d) => { d.projects.stack.agents = { checker: { definition: "merge-gate" } }; return d; });
|
|
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
|
assert.equal(business.copies["checker.stack"].state, "instance");
|
|
const edited = structuredClone(doc);
|
|
edited.projects.stack.agents.checker.vars = { "limits.tools": ["read"] };
|
|
const later = withCopies(edited, file, { rolesDir, snapshot });
|
|
const r = copyRefusal(later, "checker.stack");
|
|
assert.equal(r.code, "project-refused");
|
|
assert.match(r.message, /project stack changed checker's vars since the bus started; the change takes effect at the next start$/);
|
|
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
|
// The next boot makes the copy from the new vars.
|
|
const next = boot(dir, "acme", edited, root({})).business;
|
|
assert.equal(next.copies["checker.stack"].state, "instance");
|
|
assert.deepEqual(next.roles["checker.stack"].vars["limits.tools"], ["read"]);
|
|
});
|
|
|
|
test("A3: each project's union is recomputed from the current file: a refused union refuses its own copy and every agent in it; exclude and narrow reach the instance", () => {
|
|
const dir = tmp();
|
|
mkdirSync(join(dir, "web"), { recursive: true });
|
|
const file = businessFilePath("acme", dir);
|
|
const doc = org(dir, "acme", (d) => {
|
|
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
|
d.projects.web = { root: join(dir, "web") };
|
|
d.roles["gate-org"] = { definition: "merge-gate" };
|
|
return d;
|
|
});
|
|
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
|
assert.deepEqual(business.unions.stack, { ok: true, agents: [...Object.keys(doc.roles), "checker.stack", "gate"].sort() });
|
|
assert.equal(projectRefusal(business, "checker.stack", "web").code, "project-agent-elsewhere");
|
|
assert.equal(projectRefusal(business, "gate", "web"), null);
|
|
const system = systemFor(dir);
|
|
const tools = (b, instance) => resolveInstance({ system, business: b, instance }).limits.tools;
|
|
assert.ok(tools(business, "gate").includes("grep"));
|
|
// After boot: stack excludes something nothing defines, so its union refuses; web excludes gate and
|
|
// narrows gate-org's tools.
|
|
const edited = structuredClone(doc);
|
|
edited.projects.stack.agents.ghost = { exclude: true };
|
|
edited.projects.web.agents = { gate: { exclude: true }, "gate-org": { vars: { "limits.tools": ["read"] } } };
|
|
const later = withCopies(edited, file, { rolesDir, snapshot });
|
|
assert.equal(later.unions.stack.ok, false);
|
|
assert.match(later.unions.stack.reason, /ghost/);
|
|
// stack's own copy refuses, naming the project and why.
|
|
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
|
const own = copyRefusal(later, "checker.stack");
|
|
assert.equal(own.code, "project-refused");
|
|
assert.match(own.message, /^checker\.stack can't launch in acme: project stack refuses its agents: .*ghost/);
|
|
// Every inherited agent refuses there too.
|
|
for (const instance of ["gate", "coder", "gate-org"]) {
|
|
const r = projectRefusal(later, instance, "stack");
|
|
assert.equal(r.code, "project-refused", instance);
|
|
assert.match(r.message, new RegExp(`^${instance} can't work in project stack: project stack refuses its agents: .*ghost`));
|
|
}
|
|
assert.match(projectRefusal(later, "gate", "web").message, /the project excludes it$/);
|
|
assert.equal(projectRefusal(later, "coder", "web"), null);
|
|
// The narrow reaches the instance itself, so its resolved tools and actions are narrowed.
|
|
assert.deepEqual(later.roles["gate-org"].vars["limits.tools"], ["read"]);
|
|
assert.deepEqual(tools(later, "gate-org"), ["read"]);
|
|
// A project that stops defining its agent takes the copy with it.
|
|
const dropped = structuredClone(doc);
|
|
dropped.projects.stack.agents = { other: { definition: "merge-gate" } };
|
|
assert.match(copyRefusal(withCopies(dropped, file, { rolesDir, snapshot }), "checker.stack").message, /project stack no longer defines checker as merge-gate$/);
|
|
});
|
|
|
|
test("A3: a project narrowing a root copy narrows that copy; an organization with only project entries still gets its unions", () => {
|
|
const dir = tmp();
|
|
const doc = org(dir, "acme", (d) => {
|
|
d.projects.stack.agents = { gate: { vars: { "limits.tools": ["read", "ls"] } } };
|
|
return d;
|
|
});
|
|
const { business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
|
assert.deepEqual(resolveInstance({ system: systemFor(dir), business, instance: "gate" }).limits.tools, ["read", "ls"]);
|
|
// No root agents and no copy: the project's entries still shape the business.
|
|
const only = org(dir, "beta", (d) => { d.projects.stack.agents = { coder: { vars: { "limits.tools": ["read"] } } }; return d; });
|
|
const plain = loadWithCopies("beta", { dir, rolesDir, snapshot: null });
|
|
assert.deepEqual(plain.unions.stack.agents, Object.keys(only.roles).sort());
|
|
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
|
|
assert.equal(Object.hasOwn(plain, "copies"), false);
|
|
});
|