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 { createHash } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; import { chmodSync, 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"; import { fakeGiteaApi } from "../../bus/tests/fake-gitea-api.mjs"; import { giteaGrant, send as giteaSend } from "../../bus/src/gitea-grant.mjs"; import { organizationWriter } from "../../organization/src/organization.mjs"; import { activateOrg, listOrgs, reserveOrg } from "../../organization/src/org-registry.mjs"; import * as orgService from "../../organization/src/org-service.mjs"; import { copyBotLogin } from "../../organization/src/copy-bindings.mjs"; import { COPY_BINDING_STATUS } from "../../organization/src/copy-binding-status.mjs"; import { formatInstances } from "../src/instances.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", "user.email=test@example.invalid", "-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, prefix = "mosaic-copy-") { const root = tmp(t, prefix); 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, copyTransport = null, beforeBind = null, readOnly = [FAKE] } = {}) { const system = loadSystem({ env: f.env }); const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) }); const runtime = await startBroker({ ...boot, ...(copyTransport ? { copyTransport } : {}) }); 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), copyLaunch: (m) => runtime.copyLaunch(m), }; 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: beforeBind ? async (r) => { await beforeBind(r); return refusing("launch bind", (x) => runtime.bindLaunch(x))(r); } : refusing("launch bind", (r) => runtime.bindLaunch(r)), beforeClose: (fn) => void hooks.push(fn), copyBound: runtime.copyBound, }; 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, 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); }); // #1873 CB3: the researcher copy (research, gitea alone) bound and granted on a fake Gitea, through // the real bus, its boot check and the launcher. `sent` is every request the bus sends to the service. async function boundCopy(t, edit = (doc) => doc) { const api = await fakeGiteaApi(t, tmp(t, "cb3-api-")); const p = prepare(t, (doc) => { doc.projects.stack.remote = `${api.url}/owner/stack`; return edit(doc); }); const ctx = { configDir: join(p.root, "config"), dataRoot: p.f.dataRoot, instance: "test" }; chmodSync(ctx.configDir, 0o700); await reserveOrg(ctx, "acme"); await activateOrg(ctx, "acme"); const sent = []; const copyTransport = { ca: api.ca, send: (req) => { sent.push(`${req.method} ${req.path.split("?")[0]}`); return giteaSend(req); } }; const repo = api.addRepo("owner", "stack"); const writer = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }); const rec = () => writer.read("acme").document.copyBindings?.researcher; const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); // Bind (the owner's console) and issue (the human), on a bus that is already up. const bind = async (s) => { const owner = s.runtime.broker.bindConsole({ business: "acme", human: p.f.doc.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } }); await s.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy", binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } }); const r = rec(); const login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea"); api.addUser(login); repo.collaborators.push(login); repo.permissions[login] = "read"; await s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: api.addToken(login, "t1", ["read:repository", "read:user"]).sha1 }); return login; }; return { ...p, api, sent, copyTransport, rec, bind }; } const launchEvents = (s) => existsSync(launchLogFile(s.dataRoot, "acme")) ? readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)) : []; // One message to a new researcher thread, and the launcher's tick that takes it: the launch or the refusal. async function threadLaunch(s, name) { const project = s.call("project.list").find((x) => x.name === "stack work")?.id ?? s.call("project.create", { name: "stack work", repo: "stack" }).id; const thread = s.call("thread.create", { project, name, instance: "researcher" }).id; s.call("thread.send", { thread, body: "hello" }); await s.launcher.tick(); return until(() => launchEvents(s).find((e) => e.thread === thread && ["launch", "refused"].includes(e.event))); } test("CB3: a binding approved after boot answers restart-required; after a restart the bus proves it live, and the launcher carries one launch authority to bind", { skip, timeout: 120000 }, async (t) => { const p = await boundCopy(t); const first = await start(t, p, { copyTransport: p.copyTransport }); assert.deepEqual(first.runtime.copyBound, {}); const login = await p.bind(first); assert.equal(p.rec().state, "bound"); // The issue's own check (CB2) is the last request this bus sends for the copy. const issued = p.sent.length; assert.ok(issued > 0); // Bound and granted after this boot: restart-required at the verb, at the bus's prepare, and at its admission. assert.equal(refusedAt(first, "researcher").code, "restart-required"); const revision = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision; // A copy launch carries the revision its bundle came from; without one it is not a request. await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher" }), (e) => e.code === "invalid-request"); await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher", revision }), (e) => e.code === "restart-required"); assert.equal(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision }).code, "copy-authority-changed"); assert.deepEqual(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision, authority: "0".repeat(64) }), { ready: false, revision: null, code: "restart-required" }); assert.equal(p.sent.length, issued); await first.close(); // The next boot proves the slot live as the copy's account, and the launcher sees it bound. const s = await start(t, p, { copyTransport: p.copyTransport }); assert.deepEqual(s.runtime.copyBound, { researcher: ["gitea"] }); assert.ok(p.sent.slice(issued).includes("GET /api/v1/token")); assert.ok(p.api.requests.filter((e) => e.path === "/api/v1/token").every((e) => e.as === login)); // Final admission with an empty project list still needs the authority. const rev2 = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision; assert.equal(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2 }).code, "copy-authority-changed"); const { authority } = await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: rev2 }); assert.match(authority, /^[0-9a-f]{64}$/); assert.deepEqual(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2, authority }), { ready: true, revision: null, code: null }); // Any other instance has no authority to carry. assert.deepEqual(await s.runtime.copyLaunch({ business: "acme", instance: "gate" }), { authority: null, services: [] }); const before = p.sent.length; const launched = await threadLaunch(s, "one"); assert.equal(launched.event, "launch", JSON.stringify(launched)); // The launcher's prepare proved the slot live again at launch. assert.ok(p.sent.slice(before).includes("GET /api/v1/token")); assert.equal(s.running("researcher").length, 1); assert.equal(p.sent.filter((x) => x.includes("/api/v1/repos/")).length, 0); }); // A reissue is a new generation: the slot the launch proved is not the slot it would use. const reissue = (p, s, login, name) => s.call("credentials.issue", { agent: "researcher", service: "gitea", date: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), token: p.api.addToken(login, name, ["read:repository", "read:user"]).sha1 }); const restarted = async (t, p, opts = {}) => { const first = await start(t, p, { copyTransport: p.copyTransport }); const login = await p.bind(first); await first.close(); return { login, s: await start(t, p, { copyTransport: p.copyTransport, ...opts }) }; }; const repoCalls = (p) => p.sent.filter((x) => x.includes("/api/v1/repos/")).length; test("CB3 race: a reissue while the launcher's live proof is in flight refuses the launch restart-required; nothing runs and no credential call is made", { skip, timeout: 120000 }, async (t) => { const p = await boundCopy(t); const { login, s } = await restarted(t, p); let go, arrived; const held = new Promise((r) => { arrived = r; }); const unrule = p.api.rule((e) => e.path === "/api/v1/token", async () => { unrule(); arrived(); await new Promise((r) => { go = r; }); return { pass: true }; }); const launching = threadLaunch(s, "raced"); await held; await reissue(p, s, login, "t2"); go(); const event = await launching; assert.equal(event.event, "refused", JSON.stringify(event)); assert.equal(event.code, "restart-required"); assert.equal(s.running("researcher").length, 0); assert.equal(repoCalls(p), 0); }); test("CB3 race: a reissue between the launcher's admission and bind refuses at bind; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => { const p = await boundCopy(t); let s, login, raced = 0; ({ login, s } = await restarted(t, p, { beforeBind: async (r) => { if (r.role === "researcher" && raced++ === 0) await reissue(p, s, login, "t3"); } })); const event = await threadLaunch(s, "bindrace"); assert.equal(raced, 1); await until(() => s.running("researcher").length === 0); assert.equal(repoCalls(p), 0); const events = launchEvents(s).filter((e) => e.instance === "researcher"); // The launcher reports any bind refusal as launch-failed, naming the bus's code. assert.equal(event.event, "refused", JSON.stringify(event)); assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: restart-required"), JSON.stringify(events)); assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events)); }); test("CB3 race: an organization file change between the launcher's admission and bind refuses at bind, even with the copy authority unchanged; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => { const p = await boundCopy(t); const file = join(p.root, "config", "businesses", "acme.json"); let s, raced = 0, authority = null; // Same document, other bytes: the revision moves, the launch authority does not. ({ s } = await restarted(t, p, { beforeBind: async (r) => { if (r.role !== "researcher" || raced++ !== 0) return; authority = r.copyAuthority; writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, "utf8"))) + "\n", { mode: 0o600 }); } })); const event = await threadLaunch(s, "revrace"); assert.equal(raced, 1); assert.match(authority, /^[0-9a-f]{64}$/); await until(() => s.running("researcher").length === 0); assert.equal(repoCalls(p), 0); // The refusal was the revision's: on the file as it stands the same authority comes back. const fresh = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision; assert.equal((await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: fresh })).authority, authority); const events = launchEvents(s).filter((e) => e.instance === "researcher"); assert.equal(event.event, "refused", JSON.stringify(event)); assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: organization-changed"), JSON.stringify(events)); assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events)); }); // #1873 CB5 (CB4 review P3-2): startBroker's own wiring for a former copy a named instance now // shadows. At start the named instance's slot is held (never loaded as its credential), and the first // pass begins the shadowed revoke without waiting for the minute's sweep; the revoke deletes the copy's // token at the service through the organization's admin slot. test("CB5 runtime: a restart over a named instance that shadows a bound copy holds the slot, and the start's own pass revokes the copy at the service", { skip, timeout: 120000 }, async (t) => { const p = await boundCopy(t); const first = await start(t, p, { copyTransport: p.copyTransport }); const login = await p.bind(first); await first.close(); const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); giteaGrant({ configDir: join(p.root, "config") }).writeAdmin("acme", p.api.adminToken, { url: p.api.url, account: "mosaic-grant", rotateBy: day(60), setAt: new Date().toISOString() }); // The owner names an instance over the copy, through the writer; the binding record stays as it was. const writer = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles, dataRoot: p.f.dataRoot }); const { document, revision } = writer.read("acme"); const bindings = JSON.stringify(document.copyBindings); const d = structuredClone(document); d.roles.researcher = { definition: "research", vars: VARS, credentials: { gitea: { store: true, rotateBy: day(30) } } }; writer.write("acme", d, { revision, actor: "local-dev-owner" }); assert.equal(JSON.stringify(writer.read("acme").document.copyBindings), bindings); // The token's delete waits until the test has looked at the held slot. let go, arrived; const deleting = new Promise((r) => { arrived = r; }); const release = new Promise((r) => { go = r; }); p.api.rule((e) => e.method === "DELETE" && e.path.includes("/tokens/"), async () => { arrived(); await release; return { pass: true }; }); const s = await start(t, p, { copyTransport: p.copyTransport }); // The start's own pass, not the minute's sweep, begins it. await Promise.race([deleting, new Promise((_, no) => setTimeout(() => no(new Error("no revoke began at start")), 20000).unref())]); assert.equal(p.rec().state, "revoking"); assert.equal(p.rec().revoke.cause, "shadowed"); // The slot went before the remote step. Had start loaded it as the named instance's credential // (no hold), the discard would have refused and the record would never finish. const slot = join(p.root, "config", "secrets", "agents", "acme", "researcher", "gitea"); assert.equal(existsSync(slot), false); assert.deepEqual(s.runtime.credentialStatus("acme", "researcher").map((c) => [c.service, c.state]), [["gitea", "not-issued"]]); go(); await until(() => p.rec().state === "unbound" && p.rec().revoke?.done === true); assert.deepEqual(p.rec().revoke.remote, { gitea: "revoked" }); assert.deepEqual(p.rec().revoke.failed ?? null, null); assert.deepEqual(p.api.tokens(login), []); assert.ok(p.api.requests.some((e) => e.method === "DELETE" && e.as === "mosaic-grant")); }); // #1873 CB5 (CB4 review P3-2): the gate's loss at boot reaches the revoker through startBroker's // onLoss. A bound copy whose slot is gone at the next start is lost `credential-unavailable`, and its // binding is revoked at the service with cause `unavailable`, without waiting for a sweep. test("CB5 runtime: a bound copy whose slot is gone at restart is lost at boot, and onLoss revokes it at the service", { skip, timeout: 120000 }, async (t) => { const p = await boundCopy(t); const first = await start(t, p, { copyTransport: p.copyTransport }); const login = await p.bind(first); await first.close(); const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); giteaGrant({ configDir: join(p.root, "config") }).writeAdmin("acme", p.api.adminToken, { url: p.api.url, account: "mosaic-grant", rotateBy: day(60), setAt: new Date().toISOString() }); const slot = join(p.root, "config", "secrets", "agents", "acme", "researcher", "gitea"); assert.ok(existsSync(slot)); renameSync(slot, slot + ".gone"); const s = await start(t, p, { copyTransport: p.copyTransport }); assert.deepEqual(s.runtime.copyBound, {}); await until(() => p.rec().state === "unbound" && p.rec().revoke?.done === true, 20000); assert.equal(p.rec().revoke.cause, "unavailable"); assert.deepEqual(p.rec().revoke.remote, { gitea: "revoked" }); assert.deepEqual(p.api.tokens(login), []); assert.equal(refusedAt(s, "researcher").code, "credential-unavailable"); }); // #1873 CB5 two-organization acceptance (A3's deferred test). acme and beta each bind the shipped // research copy (researcher, gitea alone) on one fake Gitea, each run by its own bus with its own // transport. beta is registered as the organization service makes one (registry entry, owner proof, // the derived project root), so archive and delete run on it. A transport keeps the sha256 of every // credential its bus sends, never the value. const LOCAL = { provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" }; const THREAD_ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url)); const sha = (x) => createHash("sha256").update(String(x)).digest("hex"); function hashingTransport(api) { const auth = []; return { auth, ca: api.ca, send: (req) => { auth.push({ op: `${req.method} ${req.path.split("?")[0]}`, hash: sha(req.auth?.basic ? req.auth.basic.slice(1).join(":") : req.auth?.token) }); return giteaSend(req); } }; } async function twoOrgs(t) { const api = await fakeGiteaApi(t, tmp(t, "cb5-api-")); const p = prepare(t, (doc) => { doc.projects.stack.remote = `${api.url}/owner/stack`; return doc; // A short root: beta's data root is the registry's /org-runtime/beta, and a run's socket // path under it must fit 107 bytes. }, "c5-"); // The thread adapter keeps a history in the thread's store and answers with what it found there. writeFileSync(p.adapter, `exec '${process.execPath}' '${THREAD_ADAPTER}'\n`); const configDir = join(p.root, "config"); const dataRoot = p.f.dataRoot; const ctx = { configDir, dataRoot, instance: "test", principal: LOCAL }; chmodSync(configDir, 0o700); chmodSync(join(configDir, "businesses"), 0o700); await reserveOrg(ctx, "acme"); await activateOrg(ctx, "acme"); 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"] }; beta.projects.stack.remote = `${api.url}/beta/stack`; beta.projects.stack.root = join(dataRoot, "org-runtime", "beta", "projects", "stack"); writeJson(join(configDir, "businesses", "beta.json"), beta); const entry = await reserveOrg(ctx, "beta"); writeFileSync(join(entry.root, "owner.json"), JSON.stringify(LOCAL) + "\n", { mode: 0o600, flag: "wx" }); gitRepo(beta.projects.stack.root); await activateOrg(ctx, "beta"); const writer = organizationWriter({ dir: configDir, rolesDir: p.f.roles, dataRoot }); const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); const orgs = { acme: { id: "acme", repo: api.addRepo("owner", "stack"), transport: hashingTransport(api), orgRoot: undefined }, beta: { id: "beta", repo: api.addRepo("beta", "stack"), transport: hashingTransport(api), orgRoot: entry.root }, }; for (const o of Object.values(orgs)) { o.rec = () => writer.read(o.id).document.copyBindings?.researcher; o.slot = join(configDir, "secrets", "agents", o.id, "researcher", "gitea"); o.start = () => start(t, p, { businessId: o.id, orgRoot: o.orgRoot, copyTransport: o.transport, readOnly: [THREAD_ADAPTER] }); // Bind (the owner's console) and issue (the human), on a bus that is already up. o.bind = async (s) => { const owner = s.runtime.broker.bindConsole({ business: o.id, human: writer.read(o.id).document.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } }); await s.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy", binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } }); const r = o.rec(); o.login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea"); api.addUser(o.login); o.repo.collaborators.push(o.login); o.repo.permissions[o.login] = "read"; o.token = api.addToken(o.login, "t1", ["read:repository", "read:user"]).sha1; await s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: o.token }); giteaGrant({ configDir }).writeAdmin(o.id, api.adminToken, { url: api.url, account: "mosaic-grant", rotateBy: day(60), setAt: new Date().toISOString() }); }; } return { p, api, ctx, writer, orgs, day }; } const orgLaunches = (s) => existsSync(launchLogFile(s.dataRoot, s.host.business)) ? readFileSync(launchLogFile(s.dataRoot, s.host.business), "utf8").trim().split("\n").map((l) => JSON.parse(l)) : []; // Send to a thread and wait for the copy's RESULT reply; the reply says what the thread's store held. async function askThread(s, thread, body) { const sent = s.call("thread.send", { thread, body }); await s.launcher.tick(); const reply = await until(() => s.call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"), 30000) .catch((e) => { throw new Error(`no reply: ${JSON.stringify(orgLaunches(s).slice(-3))}`, { cause: e }); }); return { sent, reply, saw: JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]) }; } test("CB5 two organizations: each binds and runs its own research copy in a thread; identities, records and tokens stay apart; B works through A's revoke, archive and delete; A's raw binding or grant replayed in B is refused", { skip, timeout: 240000 }, async (t) => { const { api, ctx, writer, orgs, day } = await twoOrgs(t); const A = orgs.beta, B = orgs.acme; // Bind each, then restart each so its boot proves the slot live. for (const o of [A, B]) { const first = await o.start(); await o.bind(first); await first.close(); o.s = await o.start(); assert.deepEqual(o.s.runtime.copyBound, { researcher: ["gitea"] }, o.id); } assert.notEqual(A.login, B.login); assert.notEqual(A.rec().copyId, B.rec().copyId); assert.notEqual(A.rec().orgIncarnation, B.rec().orgIncarnation); assert.notEqual(sha(readFileSync(A.slot, "utf8")), sha(readFileSync(B.slot, "utf8"))); // One thread per organization, launched, written and read. for (const o of [A, B]) { const project = o.s.call("project.create", { name: "stack work", repo: "stack" }).id; o.thread = o.s.call("thread.create", { project, name: `work-${o.id}`, instance: "researcher" }).id; const first = await askThread(o.s, o.thread, `hello ${o.id}`); assert.deepEqual(first.saw.before, [], o.id); const launch = orgLaunches(o.s).find((e) => e.event === "launch" && e.thread === o.thread); assert.ok(launch, o.id); o.run = launch.run; [o.entry] = o.s.running("researcher"); assert.equal(o.entry.run, o.run, o.id); o.session = o.s.runtime.threadsState(o.id).find((x) => x.thread === o.thread).session; o.store = join(o.s.dataRoot, "threads", o.id, o.thread, "sessions", o.session); assert.equal(readFileSync(join(o.store, "session", "history"), "utf8"), `hello ${o.id}\n`, o.id); } // Distinct session, run, trail, workspace and slot identities. assert.notEqual(A.s.dataRoot, B.s.dataRoot); for (const k of ["thread", "session", "run"]) assert.notEqual(A[k], B[k], k); assert.notEqual(A.entry.workspace, B.entry.workspace); assert.notEqual(A.slot, B.slot); for (const [mine, other] of [[A, B], [B, A]]) { assert.ok(mine.entry.workspace.startsWith(mine.s.dataRoot + "/"), mine.id); assert.ok(mine.entry.runDir.startsWith(join(mine.s.dataRoot, "launches", mine.id) + "/"), mine.id); // No record of the other's is visible here: not its thread, its session, its run or its request. const seen = trail(mine.s.dataRoot); for (const k of ["thread", "session", "run"]) assert.equal(seen.includes(other[k]), false, `${mine.id} trail holds ${other.id}'s ${k}`); // The default organization's data root is the instance's, and every named organization's root // sits under its org-runtime: that subtree is the other bus's, not this one's. const own = readdirSync(mine.s.dataRoot, { recursive: true }).filter((x) => mine.orgRoot || !x.startsWith("org-runtime/")); assert.equal(own.join("\n").includes(other.run), false, mine.id); assert.ok(!mine.s.call("thread.list").some((x) => x.id === other.thread), mine.id); assert.throws(() => mine.s.call("thread.messages", { thread: other.thread }), BusError, mine.id); assert.ok(!mine.s.running("researcher").some((x) => x.run === other.run), mine.id); } // Reopen each: a restart of its bus, and the same thread finds its own history and session. for (const o of [A, B]) { await o.s.close(); o.s = await o.start(); const again = await askThread(o.s, o.thread, `again ${o.id}`); assert.deepEqual(again.saw.before, [`hello ${o.id}`], o.id); assert.equal(o.s.runtime.threadsState(o.id).find((x) => x.thread === o.thread).session, o.session, o.id); } // So far no request of B's bus carried A's token, and none of A's carried B's. // The issue's identity check (copy-grant verifyGiteaToken): the server version, then the token's owner. const CHECK = ["GET /api/v1/version", "GET /api/v1/token"]; const carried = (o, token) => o.transport.auth.filter((x) => x.hash === sha(token)).map((x) => x.op); assert.deepEqual(carried(B, A.token), []); assert.deepEqual(carried(A, B.token), []); assert.ok(carried(B, B.token).length > 0); // A's raw binding record written into B's file is refused, and B's file is as it was. const { document, revision } = writer.read(B.id); const replay = structuredClone(document); replay.copyBindings = { ...replay.copyBindings, researcher: A.rec() }; assert.throws(() => writer.write(B.id, replay, { revision, actor: "local-dev-owner" }), (e) => e.code === "organization-bindings-protected"); assert.equal(writer.read(B.id).revision, revision); // A's grant (its live token) issued to B's copy is refused at the service's identity check. The // issue deactivates first (design section 5): the record is left pending with no grant and the // staged file goes, so A's token is never B's credential. B's copy keeps its own identity. const bound = B.rec(); await assert.rejects(Promise.resolve().then(() => B.s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: A.token })), (e) => e instanceof BusError && e.code === "credential-identity-refused"); assert.equal(B.rec().state, "pending"); assert.equal(B.rec().services.gitea.grantId, undefined); for (const k of ["copyId", "orgIncarnation", "decision"]) assert.equal(B.rec()[k], bound[k], k); assert.equal(existsSync(B.slot), false); // The only requests that carried A's token from B's bus are the identity check that refused it. assert.deepEqual(carried(B, A.token), CHECK); // The owner's recovery is a new verified issue of B's own token, then a restart. B.token = api.addToken(B.login, "t2", ["read:repository", "read:user"]).sha1; await B.s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: B.token }); assert.equal(B.rec().state, "bound"); await B.s.close(); B.s = await B.start(); assert.deepEqual(B.s.runtime.copyBound, { researcher: ["gitea"] }); const bTokens = api.tokens(B.login).length; // B keeps working through each of A's lifecycle steps. const bWorks = async (label) => { const r = await askThread(B.s, B.thread, `after ${label}`); assert.equal(r.saw.body, `after ${label}`, label); assert.equal(B.rec().state, "bound", label); assert.ok(existsSync(B.slot), label); assert.equal(api.tokens(B.login).length, bTokens, label); assert.deepEqual(B.s.runtime.copyBound, { researcher: ["gitea"] }, label); }; const out = await A.s.call("credentials.revoke", { agent: "researcher", service: "gitea", reason: "Owner revoke" }); assert.equal(out.state, "unbound"); assert.deepEqual(out.remote, { gitea: "revoked" }); assert.deepEqual(api.tokens(A.login), []); await until(() => A.s.running("researcher").length === 0); await bWorks("revoke"); const rev = async () => (await listOrgs(ctx)).find((o) => o.id === A.id).revision; const busStop = async () => { await A.s.close(); return { stopped: true, reason: "stopped" }; }; const archived = await orgService.archiveOrg(ctx, A.id, { ifRevision: await rev(), busStop }); assert.equal(archived.outcome, "done"); await bWorks("archive"); const deleted = await orgService.deleteOrg(ctx, A.id, { confirm: A.id, ifRevision: await rev(), busStop }); assert.equal(deleted.outcome ?? "done", "done"); assert.equal(existsSync(A.slot), false); await bWorks("delete"); // Through all of it, B's bus sent A's token only in the refused issue's check, and A's never sent B's. assert.deepEqual(carried(B, A.token), CHECK); assert.deepEqual(carried(A, B.token), []); assert.ok(carried(B, B.token).length > 0); }); // #1873 CB5 operator flow: the copy's binding status in the owner's instances read and in // `mosaic org instances`, at each step a binding takes, as fixed text. The copy's token is the // sentinel: it, its hash and its leading fragment never appear in a read, the CLI text, the trail, // the launcher's log or the bus's own stderr. test("CB5 operator: the owner's instances read and mosaic org instances show each copy's binding status and next step in fixed text; the token never appears", { skip, timeout: 120000 }, async (t) => { // bus.tester on, so the tester's read below is answered and its exclusion is the broker's. const p = await boundCopy(t, (doc) => ({ ...doc, vars: { ...(doc.vars ?? {}), "bus.tester": true } })); const said = []; const write = process.stderr.write; process.stderr.write = function (chunk, ...rest) { said.push(String(chunk)); return write.call(this, chunk, ...rest); }; t.after(() => { process.stderr.write = write; }); const first = await start(t, p, { copyTransport: p.copyTransport }); const seen = []; const status = (s) => { const v = s.call("instances"); seen.push(JSON.stringify(v), formatInstances(v)); return v.copies.researcher.binding; }; const fixed = (code) => ({ status: code, next: COPY_BINDING_STATUS[code] }); // Nothing approved yet. assert.deepEqual(status(first), fixed("credential-unbound")); // Approved: the owner's next step is the issue. const owner = first.runtime.broker.bindConsole({ business: "acme", human: p.f.doc.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } }); await first.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy", binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } }); assert.equal(p.rec().state, "pending"); assert.deepEqual(status(first), fixed("issue-required")); // Issued: a restart verifies it. const r = p.rec(); const login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea"); p.api.addUser(login); const repo = p.api.repos.find((x) => x.owner === "owner" && x.name === "stack"); repo.collaborators.push(login); repo.permissions[login] = "read"; const token = p.api.addToken(login, "t1", ["read:repository", "read:user"]).sha1; await first.call("credentials.issue", { agent: "researcher", service: "gitea", date: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), token }); assert.equal(p.rec().state, "bound"); assert.deepEqual(status(first), fixed("restart-required")); await first.close(); // Verified at the next start. const s = await start(t, p, { copyTransport: p.copyTransport }); assert.deepEqual(s.runtime.copyBound, { researcher: ["gitea"] }); assert.deepEqual(status(s), fixed("ready")); // A tester's read lists the copy and never its binding status (design section 4). const tester = s.runtime.broker.bindTester({ business: "acme" }); const asTester = await s.runtime.broker.request(tester, { verb: "instances" }); assert.ok(asTester.copies?.researcher, "the tester sees the copy"); assert.equal(Object.hasOwn(asTester.copies.researcher, "binding"), false); seen.push(JSON.stringify(asTester)); // The owner's revoke. await s.call("credentials.revoke", { agent: "researcher", service: "gitea", reason: "Owner revoke" }); assert.deepEqual(status(s), fixed("credential-revoked")); // The CLI prints the status code and its next step for the copy. const text = formatInstances(s.call("instances")); assert.ok(text.includes(` researcher (copy): credential-revoked. ${COPY_BINDING_STATUS["credential-revoked"]}\n`), text); // The sentinel never appears. const leaks = (x) => [token, sha(token), token.slice(0, 8)].filter((v) => x.includes(v)); assert.deepEqual(leaks(seen.join("\n")), []); assert.deepEqual(leaks(trail(p.f.dataRoot)), []); assert.deepEqual(leaks(said.join("")), []); assert.deepEqual(leaks([...first.logs, ...s.logs].map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n")), []); }); // #1873 CB5 review: a shipped role that needs gitea and vikunja. Boot denies such a copy whatever it // binds, so its status must not send the owner to approve a gitea binding. test("CB5 operator: a copy whose role needs a service a copy cannot bind reads copy-service-unbindable and names its missing services", { skip, timeout: 60000 }, async (t) => { const p = prepare(t); const file = join(p.root, "config", "agents.json"); const doc = JSON.parse(readFileSync(file, "utf8")); doc.agents.rev = { definition: "reviewer", vars: VARS }; writeJson(file, doc, 0o600); const s = await start(t, p); const v = s.call("instances"); assert.equal(v.copies.rev.state, "unbound"); assert.deepEqual(v.copies.rev.binding, { status: "copy-service-unbindable", next: COPY_BINDING_STATUS["copy-service-unbindable"] }); // The gitea-only copy beside it still reads credential-unbound. assert.deepEqual(v.copies.researcher.binding, { status: "credential-unbound", next: COPY_BINDING_STATUS["credential-unbound"] }); const text = formatInstances(v); assert.ok(text.includes(` rev (copy): copy-service-unbindable (missing gitea, vikunja). ${COPY_BINDING_STATUS["copy-service-unbindable"]}\n`), text); assert.ok(text.includes(` researcher (copy): credential-unbound (missing gitea). ${COPY_BINDING_STATUS["credential-unbound"]}\n`), text); assert.equal(COPY_BINDING_STATUS["copy-service-unbindable"].includes("credentials.gitea"), false); });