596 lines
32 KiB
JavaScript
596 lines
32 KiB
JavaScript
import { requestHostOps } from './request-host-ops.mjs';
|
|
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
|
|
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
|
|
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
|
|
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
|
|
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
|
import { BusError } from "../../bus/src/broker.mjs";
|
|
import { Client } from "../../bus/src/client.mjs";
|
|
import { startBroker } from "../../bus/src/runtime.mjs";
|
|
import { gitEnv } from "../../seat/src/checkout.mjs";
|
|
import { launchLogFile } from "../../seat/src/session.mjs";
|
|
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
|
import { bootConfig, loadSystem } from "../src/config.mjs";
|
|
import { startTimeOf } from "../src/host.mjs";
|
|
import { createLauncher } from "../src/launcher.mjs";
|
|
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
|
|
|
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
|
const skip = sandboxProblem() ?? false;
|
|
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
|
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
|
|
|
async function until(fn, ms = 15000) {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) {
|
|
const v = await fn();
|
|
if (v) return v;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
throw new Error("timed out waiting");
|
|
}
|
|
|
|
function gitRepo(dir) {
|
|
mkdirSync(dir, { recursive: true });
|
|
const git = (...args) => {
|
|
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
};
|
|
git("init", "-q", "-b", "main");
|
|
writeFileSync(join(dir, "file.txt"), "one\n");
|
|
git("add", "file.txt");
|
|
git("commit", "-q", "-m", "one");
|
|
}
|
|
|
|
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
|
|
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
|
|
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
|
|
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
|
|
function prepare(t, edit = (doc) => doc) {
|
|
const root = tmp(t, "mosaic-copy-");
|
|
const f = fixture(root, "acme", (doc) => {
|
|
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
|
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
|
const secret = tokenFile(join(root, "secrets"), SECRET);
|
|
writeFileSync(secret, SECRET);
|
|
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
|
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
|
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
|
|
doc.projects.web = { root: join(root, "web") };
|
|
return edit(doc);
|
|
});
|
|
withoutHostCredentials(f.env, { piKey: true });
|
|
gitRepo(f.doc.projects.stack.root);
|
|
gitRepo(f.doc.projects.web.root);
|
|
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
|
|
gate: { definition: "merge-gate", vars: VARS },
|
|
researcher: { definition: "research", vars: VARS },
|
|
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
|
|
} }, 0o600);
|
|
const adapter = join(root, "adapter.sh");
|
|
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
|
return { root, f, adapter };
|
|
}
|
|
|
|
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
|
const system = loadSystem({ env: f.env });
|
|
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
|
const runtime = await startBroker(boot);
|
|
const dataRoot = boot.dataRoot;
|
|
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
|
const ops = {
|
|
...requestHostOps(runtime),
|
|
identity: (m) => runtime.identity(m.cap),
|
|
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
|
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
|
endLaunch: (m) => runtime.endLaunch(m.record),
|
|
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
|
threadsState: (m) => runtime.threadsState(m.business),
|
|
threadIdle: (m) => runtime.threadIdle(m.record),
|
|
mayLaunch: (m) => runtime.mayLaunch(m),
|
|
landHeld: (m) => runtime.landHeld(m.record),
|
|
landHolders: (m) => runtime.landHolders(m.business),
|
|
landRelease: (m) => runtime.landRelease(m.record),
|
|
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
|
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
|
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
|
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
|
};
|
|
const refusing = (what, fn) => async (m) => {
|
|
try {
|
|
return fn(m);
|
|
} catch (e) {
|
|
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
|
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
|
}
|
|
};
|
|
const hooks = [];
|
|
let closing = null;
|
|
const host = {
|
|
business: businessId,
|
|
path: runtime.path,
|
|
dataRoot,
|
|
op: refusing("launch-op", (m) => {
|
|
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
|
return ops[m.op](m);
|
|
}),
|
|
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
|
beforeClose: (fn) => void hooks.push(fn),
|
|
};
|
|
const logs = [];
|
|
const launcher = createLauncher({
|
|
host,
|
|
system: orgRoot ? { ...system, dataRoot } : system,
|
|
env: f.env,
|
|
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
|
sessionDefaults: { pollInterval: 100 },
|
|
harnessVersion: "0.85.1",
|
|
readOnly: [FAKE],
|
|
threadTickMs: 3600000,
|
|
stopTimeoutMs: 1000,
|
|
log: (l) => logs.push(l),
|
|
copies: boot.copies ?? null,
|
|
});
|
|
const close = () =>
|
|
(closing ??= (async () => {
|
|
for (const fn of hooks) await fn(0);
|
|
await runtime.close();
|
|
})());
|
|
t.after(close);
|
|
await launcher.listen();
|
|
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
|
|
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
|
const carry = async (request) => {
|
|
await launcher.tick();
|
|
return call("instance.request", { request });
|
|
};
|
|
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
|
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
|
|
}
|
|
|
|
// What the bus's trail holds: every event body, as text.
|
|
function trail(dataRoot) {
|
|
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
|
try {
|
|
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
const refusedAt = (s, instance) => {
|
|
try {
|
|
s.call("instance.launch", { instance });
|
|
} catch (e) {
|
|
assert.ok(e instanceof BusError, String(e));
|
|
return e;
|
|
}
|
|
assert.fail(`${instance} launched`);
|
|
};
|
|
|
|
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
|
|
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
const [run] = s.running("gate");
|
|
assert.equal(run.run, done.done.run);
|
|
// The grid read names it running from the bus's own claims.
|
|
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
|
|
const grid = s.call("instances");
|
|
assert.equal(grid.copies.gate.state, "instance");
|
|
assert.deepEqual(grid.copies.gate.running, [run.run]);
|
|
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
|
|
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
|
|
assert.equal(refusedAt(s, "sec").code, "unknown-role");
|
|
});
|
|
|
|
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
const e = refusedAt(s, "researcher");
|
|
assert.equal(e.code, "credential-unbound");
|
|
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
|
|
const grid = JSON.stringify(s.call("instances"));
|
|
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
|
|
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
|
|
assert.equal(text.includes(SECRET), false);
|
|
assert.equal(text.includes(p.root), false, "no host path");
|
|
}
|
|
// Nothing was asked of the launcher.
|
|
await s.launcher.tick();
|
|
assert.equal(s.running("researcher").length, 0);
|
|
});
|
|
|
|
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
const [run] = s.running("checker.stack");
|
|
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
|
|
assert.deepEqual(manifest.projects, ["stack"]);
|
|
// An org instance of the same role takes both code projects.
|
|
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
|
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
|
|
const [c] = s.running("coder");
|
|
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
|
|
|
|
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
|
|
const q = prepare(t, (doc) => {
|
|
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
|
|
return doc;
|
|
});
|
|
const r = await start(t, q);
|
|
for (const name of ["checker.web", "lint.web"]) {
|
|
const e = refusedAt(r, name);
|
|
assert.equal(e.code, "project-refused");
|
|
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
|
|
}
|
|
// The other project's agent is untouched.
|
|
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
|
|
// An inherited root copy and an organization instance still launch, without web's mount.
|
|
for (const instance of ["gate", "coder"]) {
|
|
const out = await r.carry(r.call("instance.launch", { instance }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
const [inherited] = r.running(instance);
|
|
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
|
|
}
|
|
});
|
|
|
|
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
|
|
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
|
|
|
|
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t, (doc) => {
|
|
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
|
|
doc.projects.web.agents = { gate: { exclude: true } };
|
|
return doc;
|
|
});
|
|
const s = await start(t, p);
|
|
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
const [run] = s.running("gate");
|
|
assert.deepEqual(projectsOf(run), ["stack"]);
|
|
const tools = bundleOf(run, "policy.json").tools;
|
|
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
|
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
|
// The launch record names the skipped project and the code, never a reason text.
|
|
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
|
|
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
|
|
});
|
|
|
|
// Writes the organization file the way a human edit does, outside any writer verb.
|
|
const editOrg = (p, change) => {
|
|
const file = join(p.root, "config", "businesses", "acme.json");
|
|
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
change(doc);
|
|
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
|
};
|
|
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
|
|
// so a repeat reload drops the keep instead.
|
|
const reload = (s) => {
|
|
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
|
|
catch (e) { if (e.code !== "organization-no-change") throw e; }
|
|
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
|
|
};
|
|
// What the run's first turn said, from inside the real sandbox, about the web checkout.
|
|
async function webSeen(s, run) {
|
|
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
|
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
|
|
const said = join(run.runDir, "turns", "0001.stderr");
|
|
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
|
|
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
|
|
}
|
|
|
|
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
|
|
const p = prepare(t);
|
|
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
|
|
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
|
const s = await start(t, p);
|
|
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
let [run] = s.running("gate");
|
|
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
|
assert.equal(await webSeen(s, run), "READABLE");
|
|
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
|
await until(() => s.running("gate").length === 0);
|
|
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
|
|
// The project excludes gate, then refuses its whole union.
|
|
editOrg(p, (d) => { d.projects.web.agents = exclude; });
|
|
reload(s);
|
|
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
[run] = s.running("gate");
|
|
assert.deepEqual(projectsOf(run), ["stack"]);
|
|
assert.equal(await webSeen(s, run), "ABSENT");
|
|
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
|
await until(() => s.running("gate").length === 0);
|
|
}
|
|
// The earlier work is still on the host, untouched.
|
|
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
|
});
|
|
|
|
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
const op = s.host.op;
|
|
let changed = false;
|
|
s.host.op = async (m) => {
|
|
const out = await op(m);
|
|
if (!changed && m.op === "request.admission" && m.project === "web") {
|
|
changed = true;
|
|
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
|
}
|
|
return out;
|
|
};
|
|
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(changed, true);
|
|
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
|
|
assert.equal(s.running("gate").length, 0);
|
|
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
|
|
reload(s);
|
|
const revision = s.call("instances").revision;
|
|
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
|
|
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
|
|
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
|
|
// The launch boundary takes an instance only with the revision its bundle came from.
|
|
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
|
|
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
|
|
});
|
|
|
|
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
|
|
// workspace keeps a web checkout an earlier launch made.
|
|
async function launchBoth(s) {
|
|
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
const [run] = s.running("gate");
|
|
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
|
assert.equal(await webSeen(s, run), "READABLE");
|
|
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
|
await until(() => s.running("gate").length === 0);
|
|
}
|
|
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
|
|
|
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
|
|
const p = prepare(t);
|
|
readsWeb(p);
|
|
const s = await start(t, p);
|
|
await launchBoth(s);
|
|
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
|
|
const op = s.host.op;
|
|
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
|
|
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
const [run] = s.running("gate");
|
|
assert.deepEqual(projectsOf(run), ["stack"]);
|
|
assert.equal(await webSeen(s, run), "ABSENT");
|
|
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
|
});
|
|
|
|
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
|
|
const p = prepare(t);
|
|
readsWeb(p);
|
|
const s = await start(t, p);
|
|
await launchBoth(s);
|
|
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
|
|
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
|
|
renameSync(join(ws, "web"), join(ws, "kept"));
|
|
symlinkSync(join(ws, "kept"), join(ws, "web"));
|
|
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
|
reload(s);
|
|
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
|
assert.equal(s.running("gate").length, 0);
|
|
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
|
|
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
|
|
// A plain file there refuses the same way.
|
|
renameSync(join(ws, "web"), join(ws, "link"));
|
|
writeFileSync(join(ws, "web"), "not a checkout\n");
|
|
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
|
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
|
|
});
|
|
|
|
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
|
|
for (const [instance, narrow, after] of [
|
|
// An inherited agent's narrow in its project.
|
|
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
|
|
// A project agent's define: its vars change after boot.
|
|
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
|
|
]) {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
const op = s.host.op;
|
|
let landed = false;
|
|
// The narrow lands after every launcher read, just before the bus's last admission answers.
|
|
s.host.op = async (m) => {
|
|
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
|
|
return op(m);
|
|
};
|
|
let out = await s.carry(s.call("instance.launch", { instance }).request);
|
|
assert.equal(landed, true, instance);
|
|
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
|
|
assert.equal(s.running(instance).length, 0, instance);
|
|
// The next launch resolves from the narrowed file.
|
|
if (after === "launches") {
|
|
out = await s.carry(s.call("instance.launch", { instance }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
|
|
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
|
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
|
} else {
|
|
assert.equal(refusedAt(s, instance).code, after, instance);
|
|
}
|
|
await s.close();
|
|
}
|
|
});
|
|
|
|
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
|
|
const p = prepare(t);
|
|
let s = await start(t, p);
|
|
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
|
|
reload(s);
|
|
const copy = s.call("instances").copies["checker.stack"];
|
|
assert.equal(copy.state, "refused");
|
|
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
|
|
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
|
|
await s.close();
|
|
s = await start(t, p);
|
|
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
|
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
|
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
|
|
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
|
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
|
});
|
|
|
|
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const rootFile = join(p.root, "config", "agents.json");
|
|
const root = JSON.parse(readFileSync(rootFile, "utf8"));
|
|
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
|
|
writeFileSync(rootFile, JSON.stringify(root));
|
|
const s = await start(t, p);
|
|
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
|
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
|
|
reload(s);
|
|
const copy = s.call("instances").copies["new.stack"];
|
|
assert.equal(copy.state, "refused");
|
|
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
|
|
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
|
|
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
|
|
// Taking the project's define back out frees the root copy again.
|
|
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
|
|
reload(s);
|
|
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
|
});
|
|
|
|
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
assert.equal(s.call("instances").copies.gate.state, "instance");
|
|
const file = join(p.root, "config", "businesses", "acme.json");
|
|
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
|
|
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
|
// A writer verb reloads the broker from the file, as any human edit would.
|
|
const { revision } = s.call("instances");
|
|
s.call("veto.set", { revision, op: "keep", name: "coder" });
|
|
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
|
|
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
const [run] = s.running("gate");
|
|
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
|
|
});
|
|
|
|
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const beta = businessDoc(p.root, "beta");
|
|
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
|
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
|
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
|
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
|
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
|
const a = await start(t, p);
|
|
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
|
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
|
const runs = {};
|
|
for (const [id, s] of [["acme", a], ["beta", b]]) {
|
|
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
const [run] = s.running("gate");
|
|
runs[id] = run;
|
|
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
|
|
await until(() => existsSync(join(run.runDir, "session")));
|
|
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
|
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
|
|
}
|
|
assert.notEqual(runs.acme.run, runs.beta.run);
|
|
// Neither bus, trail or data root holds the other's run.
|
|
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
|
|
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
|
|
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
|
|
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
|
|
}
|
|
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
|
|
});
|
|
|
|
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
const rootFile = join(p.root, "config", "agents.json");
|
|
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
|
|
// A launch, a relaunch and a reload all keep the boot's copy set.
|
|
for (let i = 0; i < 2; i++) {
|
|
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
|
|
const { revision } = s.call("instances");
|
|
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
|
|
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
|
|
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
|
|
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
|
await until(() => s.running("gate").length === 0);
|
|
}
|
|
await s.close();
|
|
// A restart reads the file again.
|
|
const next = await start(t, p);
|
|
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
|
|
assert.equal(refusedAt(next, "gate").code, "unknown-role");
|
|
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
|
|
});
|
|
|
|
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const s = await start(t, p);
|
|
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
|
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
|
|
for (const instance of ["gate", "checker.stack"]) {
|
|
const out = await s.launcher.fromSession(chief.cap, instance);
|
|
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
|
|
}
|
|
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
|
|
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
|
|
assert.equal(s.launcher.registry.sessions.length, 0);
|
|
});
|
|
|
|
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
|
|
const p = prepare(t);
|
|
const beta = businessDoc(p.root, "beta");
|
|
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
|
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
|
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
|
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
|
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
|
const a = await start(t, p);
|
|
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
|
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
|
assert.notEqual(a.dataRoot, b.dataRoot);
|
|
assert.equal(a.boot.copies.org, "acme");
|
|
assert.equal(b.boot.copies.org, "beta");
|
|
// beta isn't excluded from sec, and has no project agent of acme's.
|
|
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
|
|
const { request } = a.call("instance.launch", { instance: "gate" });
|
|
const done = await a.carry(request);
|
|
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
|
const [run] = a.running("gate");
|
|
// beta's bus holds none of it: no request, no claim, no run, no session.
|
|
assert.throws(() => b.call("instance.request", { request }), BusError);
|
|
assert.deepEqual(b.call("instances").copies.gate.running, []);
|
|
assert.equal(trail(b.dataRoot).includes(run.run), false);
|
|
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
|
|
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
|
|
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
|
assert.equal(b.running("gate").length, 0);
|
|
});
|