diff --git a/packages/bus/tests/copy-revoke.test.mjs b/packages/bus/tests/copy-revoke.test.mjs index d7b5865b4..d0863dd59 100644 --- a/packages/bus/tests/copy-revoke.test.mjs +++ b/packages/bus/tests/copy-revoke.test.mjs @@ -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' }); +}); diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index d9c407b99..b374b5549 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -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 /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); +});