test(bus,cli): CB5 two-organization acceptance and the CB4 deferred P3 tests (#1873)

The two-org test boots real buses for two organizations with a shipped
Gitea-requiring thread role, binds each copy through its own owner
decision and verified issue, and checks identity separation, record
isolation, token isolation, B through A's revoke, archive and delete,
and refusal of a replayed raw A binding or A token in B.

P3-1 shadowed cleanup, P3-2 runtime revoke wiring, P3-3 ordinary-run
stop. Each passes on BASE; guard-removal mutants recorded.
This commit is contained in:
jason.woltje committed 2026-11-12 19:19:22 -06:00
1 parent 34dd331016
commit dbb69c8a41
2 files changed
+357 -7

No files matched your search

+78 -1
View File
@@ -62,7 +62,7 @@ async function world(t) {
// they stand, as a restart of the bus process would; boot() runs the gate's boot proof and promotes.
// stopRuns acknowledges each run the broker told the launcher to kill, as the launcher's exit does,
// unless `ack` is false. The revoke's remote step goes to the organization's admin slot.
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false } = {}) {
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false, definitions = null } = {}) {
const { root, rolesDir, api } = w;
const owner = id === 'demo' ? 'owner' : id;
const doc = { ...businessDoc(root, id), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
@@ -72,6 +72,7 @@ async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'r
// The project root the organization service derives (archive and delete check it).
doc.projects.stack.root = join(w.data, 'org-runtime', id, 'projects', 'stack');
if (project) doc.projects.stack.agents = project;
if (definitions) doc.definitions = definitions;
const file = writeJson(join(root, `businesses/${id}.json`), doc);
chmodSync(join(root, 'businesses'), 0o700);
const ctx = { configDir: root, dataRoot: w.data, instance: 'test', principal: LOCAL };
@@ -746,3 +747,79 @@ test('CB4 a second organization launches and calls throughout the first one\'s r
assert.ok(existsSync(acme.slot));
assert.deepEqual(demo.bot.tokens, []);
});
// #1873 CB5 (CB4 review P3-1): the gate finds a shadowing name only in the organization it serves,
// and the runtime gives a gate only to a boot that made a copy (cli config.mjs bootConfig passes no
// snapshot otherwise). A named instance the owner wrote over the only copy while the bus was down
// leaves the next start with no copy and no gate; only the revoker's own sweep then finds the former
// copy's record and ends its binding.
test('CB5 shadowing in an organization no gate serves: only the revoker\'s own sweep finds the former copy, and it revokes the binding', async (t) => {
const w = await world(t);
const f = await org(t, w);
await f.bind();
assert.equal(f.rec().state, 'bound');
assert.ok(existsSync(f.slot));
f.edit((d) => { d.roles[f.name] = { definition: 'research', credentials: { gitea: d.roles.coder.credentials.gitea } }; });
f.start();
// The start makes no copy of the name, so the runtime would build no gate for this organization.
assert.deepEqual(Object.keys(f.snapshot.copies ?? {}), []);
const revoker = copyRevoker({ writer: f.writer, gate: () => null, credentials: f.credentials, hooks: {}, stopRuns: (...a) => f.stopRuns(...a),
remote: giteaCopyRemote({ grant: f.grant, read: (x) => f.writer.read(x).document, incarnation: (x) => readOrgEntrySync(f.root, x)?.incarnation ?? null }),
definitions: () => ({}) });
assert.equal(revoker.pending(f.id, f.name), true);
revoker.sweep(f.id);
await until(() => tombstoned(f), 'shadowed tombstone');
assert.equal(f.rec().revoke.cause, 'shadowed');
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
assert.ok(!existsSync(f.slot));
assert.deepEqual(f.bot.tokens, []);
assert.equal(revoker.pending(f.id, f.name), false);
// A second pass finds a finished tombstone and starts nothing.
const deletes = w.api.calls().filter((c) => c.startsWith('DELETE')).length;
revoker.sweep(f.id);
await sleep(50);
assert.equal(revoker.has(f.id, f.name), false);
assert.equal(w.api.calls().filter((c) => c.startsWith('DELETE')).length, deletes);
});
// #1873 CB5 (CB4 review P3-3): an organization's own definition that needs gitea alone is not a
// thread definition, so its copy runs on the ordinary path: a role claim, no thread. A revoke ends
// that claim, kills the run, drains its call in flight and leaves its session dead.
const GITTER = { rev: 1, role: { tools: ['read'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] },
credentials: [{ service: 'gitea', scopes: ['read:repository', 'read:user'] }] }, instructions: 'Read the stack repository.' };
test('CB5 a revoke of a copy on the ordinary run path ends its role claim, kills the run, drains its call and kills its session', async (t) => {
const w = await world(t);
const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'org:gitter' } }, definitions: { gitter: GITTER } });
f.rootAgents = {};
f.start();
await f.bind({ level: 'project', project: 'stack', name: 'rev' });
assert.equal(f.rec().state, 'bound');
f.start();
await f.boot();
assert.deepEqual(Object.keys(f.gate.bound()), ['rev.stack']);
// An ordinary run: a thread is refused, none is required.
const revision = f.writer.read(f.id).revision;
const { authority } = await f.gate.prepare(f.id, f.name, revision);
assert.throws(() => f.b.bindLaunch({ business: f.id, role: f.name, run: 'run-t', harness: 'pi', thread: 'x', copyAuthority: authority, revision }), refuses('thread-not-allowed'));
const cap = f.b.bindLaunch({ business: f.id, role: f.name, run: 'run-1', harness: 'pi', copyAuthority: authority, revision });
await f.call(cap, 'role.claim');
const claim = () => f.store.get('SELECT op, holder_run, reason, by FROM role_claims WHERE business=? AND role=? ORDER BY seq DESC LIMIT 1', f.id, f.name);
assert.deepEqual({ ...claim() }, { op: 'claim', holder_run: 'run-1', reason: null, by: 'run-1' });
assert.equal((await f.use(cap)).status, 200);
const held = f.hold((e) => e.path === f.GET.path);
const inflight = f.use(cap).then((x) => ({ ok: x }), (e) => ({ err: e }));
await held.arrived;
assert.equal(f.gate.inflight(f.id, f.name), 1);
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
assert.equal(out.state, 'unbound');
assert.equal(f.gate.inflight(f.id, f.name), 0, 'drained before the revoke answered');
held.release();
const settled = await inflight;
assert.ok(settled.err || settled.ok?.status === 0, 'the in-flight call was aborted');
// The claim ended for the revoke, by the bus, and the run got its kill end.
assert.deepEqual({ ...claim() }, { op: 'revoke', holder_run: 'run-1', reason: 'copy-revoked', by: 'bus' });
assert.deepEqual(f.killed(), ['run-1']);
assert.throws(() => f.b.copySession(cap), (e) => e instanceof BusError);
await assert.rejects(f.call(cap, 'role.claim'), (e) => e instanceof BusError);
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
});
+279 -6
View File
@@ -8,6 +8,7 @@ import { requestHostOps } from './request-host-ops.mjs';
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";
@@ -24,9 +25,10 @@ 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 { send as giteaSend } from "../../bus/src/gitea-grant.mjs";
import { giteaGrant, send as giteaSend } from "../../bus/src/gitea-grant.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { activateOrg, reserveOrg } from "../../organization/src/org-registry.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";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
@@ -60,8 +62,8 @@ function gitRepo(dir) {
// 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-");
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";
@@ -86,7 +88,7 @@ function prepare(t, edit = (doc) => doc) {
return { root, f, adapter };
}
async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTransport = null, beforeBind = null } = {}) {
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 } : {}) });
@@ -143,7 +145,7 @@ async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTran
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [FAKE],
readOnly,
threadTickMs: 3600000,
stopTimeoutMs: 1000,
log: (l) => logs.push(l),
@@ -756,3 +758,274 @@ test("CB3 race: an organization file change between the launcher's admission and
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 <dataRoot>/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);
});