Files
stack/packages/bus/tests/copy-revoke.test.mjs
jason.woltje 4b35e3c1d2 fix(bus): CB5 re-check, a running or failed revoke outranks copy-service-unbindable; unbind summary tested (#1873)
- copy-launch status(): a fenced or closed copy falls through to the gate check, so the owner
  sees credential-revoking or credential-revoke-failed for a binding issued to a copy whose role
  also needs a service a copy cannot bind.
- copy-bindings: an unbind raise reads change 'unbind' in the raise answer, every inbox and trail.
2026-11-12 19:19:22 -06:00

843 lines
52 KiB
JavaScript

// #1873 CB4 (design sections 3 and 6, section 8 row CB4): every lifecycle event that ends an agent
// copy's credential binding closes its admissions, drains its calls in flight, stops each of its runs
// and waits for the stop to be acknowledged, removes its slot, revokes the grant at the service
// through the organization's managed admin slot (or records why not), and only then writes the
// tombstone. A crash at any point leaves a pending op that the next start finishes under the same
// id; nothing in between resolves, launches or reloads the copy's token. The fake Gitea is
// fake-gitea-api.mjs on 127.0.0.1; the live check against a real Gitea is copy-revoke-docker.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { Broker, BusError } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { Credentials } from '../src/credentials.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { copyGrant } from '../src/copy-grant.mjs';
import { copyLaunchGate } from '../src/copy-launch.mjs';
import { copyRevoker, giteaCopyRemote, managedCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs';
import { giteaGrant, send } from '../src/gitea-grant.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
import { activateOrg, listOrgs, readOrgEntrySync, 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 { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const LOCAL = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const CODES = ['credential-revoke-failed', 'credential-revoking', 'credential-revoked', 'credential-unbound', 'credential-unavailable',
'credential-expired', 'credential-role-changed', 'copy-unavailable', 'copy-refused', 'org-archived', 'org-unavailable'];
const refuses = (code) => (e) => { assert.ok(e instanceof BusError || e?.code, String(e?.stack ?? e)); assert.equal(e.code, code); return true; };
async function until(pred, what, ms = 8000) {
const end = Date.now() + ms;
while (Date.now() < end) {
if (await pred()) return;
await sleep(10);
}
assert.fail(`timed out waiting for ${what}`);
}
// One scratch root with one fake Gitea, shared by every organization a test makes, as one stack
// instance's configuration directory is.
async function world(t) {
const root = mkdtempSync(join(tmpdir(), 'cb4-')), rolesDir = rolesCopy(root);
t.after(() => rmSync(root, { recursive: true, force: true }));
const api = await fakeGiteaApi(t, root);
const data = join(root, 'data'); mkdirSync(data, { mode: 0o700 });
return { root, rolesDir, api, data };
}
// Organization `id` with project stack on the fake Gitea and the root copy `name` (research: it
// needs gitea alone). bind() has the owner bind the copy and issue one live token for it. start()
// builds the bus for the organization (store, credentials, gate, revoker, broker) on the files as
// 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, 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 } };
doc.projects.stack.remote = `${api.url}/${owner}/stack`;
// A project set up in git remote mode names its repository there, with no top-level remote.
if (gitOnly) { doc.projects.stack.git = { mode: 'remote', remote: doc.projects.stack.remote }; delete doc.projects.stack.remote; }
// 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 };
const entry = await reserveOrg(ctx, id);
// The organization service's owner proof, as createOrg writes it.
writeFileSync(join(entry.root, 'owner.json'), JSON.stringify(LOCAL) + '\n', { mode: 0o600, flag: 'wx' });
mkdirSync(doc.projects.stack.root, { recursive: true, mode: 0o700 });
await activateOrg(ctx, id);
const writer = organizationWriter({ dir: root, rolesDir });
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
const grant = giteaGrant({ configDir: root, ca: api.ca, timeoutMs: 3000 });
if (admin) grant.writeAdmin(id, api.adminToken, { url: api.url, account: 'mosaic-grant', rotateBy: adminRotateBy, setAt: new Date().toISOString() });
const repo = api.addRepo(owner, 'stack');
const storeDir = join(root, `store-${id}`); mkdirSync(storeDir, { mode: 0o700 });
const f = {
id, name, root, rolesDir, api, file, writer, ctx, storeRoot, grant, repo, stopMs, ack: true, sent: [], stops: [],
clock: Date.now(), hooks: {}, rootAgents: { [name]: { definition } }, project: null, runs: [],
GET: { method: 'GET', path: `/api/v1/repos/${owner}/stack` },
};
f.slot = join(storeRoot, id, name, 'gitea');
f.rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
f.advance = (days) => { f.clock += days * 86400000; };
// The owner's edit of the organization file, through the writer (a hand edit breaks the registry
// digest fence, and the writer then refuses every write, a revoke's included).
f.edit = (fn) => { const { document, revision } = writer.read(id); const d = structuredClone(document); fn(d); writer.write(id, d, { revision, actor: 'local-dev-owner' }); };
f.call = (cap, verb, args = {}) => Promise.resolve().then(() => f.b.request(cap, { verb, args }));
f.repoCalls = () => f.sent.filter((x) => x.includes('/api/v1/repos/')).length;
f.killed = () => f.store.all("SELECT body FROM events WHERE kind='action.allowed'").map((r) => JSON.parse(r.body))
.filter((x) => x.action === 'lifecycle.end' && x.kill === true).flatMap((x) => x.runs);
f.stopRuns = async (business, instance, opts) => {
f.stops.push({ instance, ...opts });
const p = f.b.stopCopyRuns(business, instance, { ...opts, timeoutMs: f.stopMs });
if (f.ack) for (const run of f.killed()) try { f.b.endLaunch({ business, run, reason: 'killed' }); } catch {}
return p;
};
// A start of the bus process on the files as they stand: the boot snapshot from `rootAgents`
// (the root agents file at this start), and a fresh store handle, credentials, gate and revoker.
f.start = (rootAgents = f.rootAgents) => {
try { f.store?.close(); } catch {}
try { f.credentials?.close(); } catch {}
const now = JSON.parse(readFileSync(file, 'utf8'));
const snapshot = materializeCopies({ orgId: id, doc: now, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: rootAgents }, { rolesDir }) }, rolesDir });
const store = new Store(storeDir);
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
const service = hireDecisions({ writer, rolesDir, dataRoot: storeDir, configDir: root, assertClean: () => {}, copies: (x) => snapshot.org === x ? snapshot : null });
const transport = async (req) => { f.sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
let revoker = null;
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(f.clock),
onLoss: (business, instance, cause) => revoker?.start(business, instance, { cause, actor: 'bus' }) });
revoker = copyRevoker({ writer, gate, credentials, hooks: f.hooks, stopRuns: (...a) => f.stopRuns(...a),
remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document, incarnation: (x) => readOrgEntrySync(root, x)?.incarnation ?? null }), definitions: () => snapshot.definitions ?? {} });
let promoted = snapshot;
const business = () => {
const d = loadWithCopies(id, { dir: root, rolesDir, snapshot: promoted });
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
};
const issueGrant = (...a) => copyGrant({ credentials, writer, copies: snapshot, organizations: () => [id], ca: api.ca, timeoutMs: 3000 })(...a);
const b = new Broker({ store, businesses: { [id]: business() }, copyLaunch: gate, copyRevoker: revoker,
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness(id, business()); return r; } },
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issueGrant(...a) });
Object.assign(f, { store, credentials, gate, revoker, b, snapshot, business, reload: () => b.reloadBusiness(id, business()) });
f.owner = b.bindConsole({ business: id, human: 'jason', subject: SUBJECT });
f.human = b.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
f.boot = async () => {
await gate.boot(id);
promoted = promoteCopies(snapshot, gate.bound());
b.reloadBusiness(id, business());
};
return f;
};
t.after(() => { try { f.store?.close(); } catch {} try { f.credentials?.close(); } catch {} });
f.bind = async (hire = { level: 'root', name }) => {
await f.call(f.owner, 'hire.start.console', { op: 'change', ...hire, reason: 'Bind the copy', binding: { services } });
const r = f.rec();
f.login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
f.bot = api.addUser(f.login).user;
repo.collaborators.push(f.login); repo.permissions[f.login] = perm;
f.token = api.addToken(f.login, `t${api.issued.length}`, services.gitea.scopes);
await f.call(f.human, 'credentials.issue', { agent: name, service: 'gitea', date: ahead(30), token: f.token.sha1 });
};
f.launch = async (run) => {
f.project ??= (await f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
const thread = (await f.call(f.human, 'thread.create', { project: f.project, name: run, instance: name })).id;
const revision = writer.read(id).revision;
const { authority } = await f.gate.prepare(id, name, revision);
const cap = f.b.bindLaunch({ business: id, role: name, run, harness: 'pi', thread, copyAuthority: authority, revision });
f.runs.push(run);
return { run, thread, authority, cap };
};
f.use = (cap, request = f.GET) => Promise.resolve().then(() => f.gate.call(f.b.copySession(cap), request));
// The launcher's prepare at the file's revision (none when the file is gone: a deleted organization).
f.prepare = () => Promise.resolve().then(() => {
let revision;
try { revision = writer.read(id).revision; } catch { revision = undefined; }
return f.gate.prepare(id, name, revision);
});
// The next request matching `match` waits at the fake server until release() lets it through.
f.hold = (match) => {
let release, arrived;
const go = new Promise((r) => { release = r; });
const seen = new Promise((r) => { arrived = r; });
const unrule = api.rule((e, body) => match(e, body), async () => { unrule(); arrived(); await go; return { pass: true }; });
return { release: () => release(), arrived: seen };
};
f.start();
return f;
}
// A bound, booted copy with one live run: what every lifecycle test starts from.
async function live(t, w, opts) {
const f = await org(t, w, opts);
await f.bind();
assert.equal(f.rec().state, 'bound');
f.start();
await f.boot();
assert.deepEqual(Object.keys(f.gate.bound()), [f.name]);
const r = await f.launch('run-1');
assert.equal((await f.use(r.cap)).status, 200);
return { f, r };
}
const tombstoned = (f) => f.rec()?.state === 'unbound' && f.rec()?.revoke?.done === true;
// The end state of every completed revoke: the run is gone, the slot is gone, the token is gone at
// the service (or the record says why it was not attempted there), and the copy refuses with
// nothing sent.
async function assertRevoked(f, r, { cause, code, remote = 'revoked' }) {
await until(() => tombstoned(f), `${cause} tombstone`);
const rec = f.rec();
assert.equal(rec.revoke.cause, cause);
assert.deepEqual(rec.revoke.remote, { gitea: remote });
assert.deepEqual(rec.services, {});
assert.ok(!existsSync(f.slot), 'slot removed');
if (remote === 'revoked') assert.deepEqual(f.bot.tokens, [], 'token deleted at the service');
if (r) {
assert.ok(f.killed().includes(r.run), 'the run was told to stop');
assert.throws(() => f.b.copySession(r.cap), (e) => e instanceof BusError);
}
const sent = f.sent.length;
await assert.rejects(f.prepare(), refuses(code));
assert.equal(f.sent.length, sent, 'a refused prepare sends nothing');
assert.ok(!JSON.stringify(rec).includes(f.token.sha1));
}
test('CB4 every revoke refusal code is in the refusal table with a rail and a next step naming no path', () => {
for (const code of CODES) {
const entry = REFUSALS[code];
assert.ok(entry, code);
assert.match(entry.rail, /^R\d+[/:]/, code);
assert.ok(entry.nextStep.length > 20, code);
assert.ok(!/(^|\s)(\/|~\/)\S/.test(entry.nextStep), `${code} names a path`);
}
assert.deepEqual(REVOKE_POINTS.length, 10);
});
for (const verb of ['credentials.revoke', 'credentials.delete']) {
test(`CB4 the owner's ${verb} of a bound copy: closed, drained, every run stopped and acknowledged, slot gone, token revoked at Gitea, then tombstone`, async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const r2 = await f.launch('run-2');
// A call in flight when the revoke starts is aborted and settles before the stop.
const held = f.hold((e) => e.path === f.GET.path);
let settledBeforeRelease = false, released = false;
const inflight = f.use(r2.cap).then((x) => ({ ok: x }), (e) => ({ err: e })).finally(() => { settledBeforeRelease = !released; });
await held.arrived;
assert.equal(f.gate.inflight(f.id, f.name), 1);
const args = verb === 'credentials.revoke' ? { agent: f.name, service: 'gitea', reason: 'Owner revoke' } : { agent: f.name, reason: 'Owner delete' };
const out = await f.call(f.human, verb, args);
// The drain aborted the call and waited for it: it settled before the server would ever answer.
await new Promise((r) => setImmediate(r));
assert.equal(f.gate.inflight(f.id, f.name), 0, 'drained before the revoke answered');
assert.equal(settledBeforeRelease, true, 'the in-flight call settled before the server released it');
released = true;
held.release();
assert.equal(out.state, 'unbound');
assert.deepEqual(out.remote, { gitea: 'revoked' });
const settled = await inflight;
assert.ok(settled.err || settled.ok?.status === 0, 'the in-flight call was aborted');
assert.equal(f.gate.inflight(f.id, f.name), 0);
// Both runs got one kill end, and each acknowledged before the tombstone was written.
assert.deepEqual(f.killed().sort(), ['run-1', 'run-2']);
assert.equal(f.stops.length, 1);
assert.equal(f.stops[0].op, f.rec().revoke.id);
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked' });
assert.throws(() => f.b.copySession(r2.cap), (e) => e instanceof BusError);
// A second revoke is a no-op answer, never a second remote call.
const deletes = w.api.calls().filter((c) => c.startsWith('DELETE')).length;
await assert.rejects(f.call(f.human, verb, args), (e) => e instanceof BusError);
assert.equal(w.api.calls().filter((c) => c.startsWith('DELETE')).length, deletes);
});
}
test('CB4 only the owner revokes: the tester, a reader, a member with org.hire and the copy itself refuse, and nothing changes', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const member = f.b.bindHireConsole({ business: f.id, actor: 'pat', subject: { provider: 'test', iss: 'https://issuer.test', sub: 'pat' }, rights: ['org.hire', 'decide'] });
const callers = { tester: f.b.bindTester({ business: f.id }), reader: f.b.bindReader({ business: f.id }), member, copy: r.cap };
for (const [who, cap] of Object.entries(callers))
for (const [verb, args] of [['credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Not mine' }], ['credentials.delete', { agent: f.name, reason: 'Not mine' }]])
await assert.rejects(f.call(cap, verb, args), (e) => e instanceof BusError, `${who} ${verb}`);
assert.equal(f.rec().state, 'bound');
assert.equal(f.rec().revoke, undefined);
assert.equal(f.gate.closed(f.id, f.name), false);
assert.equal((await f.use(r.cap)).status, 200);
assert.equal(f.bot.tokens.length, 1);
assert.deepEqual(f.killed(), []);
});
test('CB4 unbind through the owner\'s decision revokes the copy: credential-unbound, token gone', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'root', name: f.name, reason: 'Unbind the copy', binding: { services: null } });
await assertRevoked(f, r, { cause: 'unbind', code: 'credential-unbound' });
});
test('CB4 slot removal: the next call refuses credential-unavailable and the binding is revoked', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
unlinkSync(f.slot);
await assert.rejects(f.use(r.cap), refuses('credential-unavailable'));
await assertRevoked(f, r, { cause: 'unavailable', code: 'credential-unavailable' });
});
test('CB4 expiry: past rotateBy the call refuses credential-expired and the binding is revoked', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
f.advance(31);
const before = f.repoCalls();
await assert.rejects(f.use(r.cap), refuses('credential-expired'));
assert.equal(f.repoCalls(), before);
await assertRevoked(f, r, { cause: 'expired', code: 'credential-expired' });
});
test('CB4 expiry found by the sweep with no call at all revokes too', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
f.advance(31);
f.revoker.sweep(f.id);
await assertRevoked(f, r, { cause: 'expired', code: 'credential-expired' });
});
test('CB4 role change: the definition the binding pinned changed, the call refuses and the binding is revoked', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const role = join(f.rolesDir, 'research.json');
const d = JSON.parse(readFileSync(role, 'utf8'));
writeFileSync(role, JSON.stringify({ ...d, title: `${d.title} changed` }, null, 2) + '\n');
await assert.rejects(f.use(r.cap), refuses('credential-role-changed'));
await assertRevoked(f, r, { cause: 'role-changed', code: 'credential-role-changed' });
});
test('CB4 fire: a copy whose root agent is gone at the next start (the fire landed, its revoke never began) is revoked at boot', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
// The root agent was fired and the bus died before the revoke published anything.
f.start({});
await f.boot();
assert.deepEqual(f.gate.bound(), {});
f.revoker.sweep(f.id);
await until(() => tombstoned(f), 'retire tombstone');
assert.equal(f.rec().revoke.cause, 'retire');
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
assert.ok(!existsSync(f.slot));
assert.deepEqual(f.bot.tokens, []);
await assert.rejects(f.prepare(), refuses('copy-unavailable'));
});
test('CB4 fire through the owner\'s decision: the fire is written, the copy\'s runs are stopped and its binding is revoked after the write', async (t) => {
const w = await world(t);
const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'research' } } });
f.rootAgents = {};
f.start();
await f.bind({ level: 'project', project: 'stack', name: 'rev' });
f.start();
await f.boot();
const r = await f.launch('run-1');
assert.equal((await f.use(r.cap)).status, 200);
// The fire decision itself must not be refused decision-stale by a revoke written under it.
const out = await f.call(f.owner, 'hire.start.console', { op: 'fire', level: 'project', project: 'stack', name: 'rev', reason: 'Fire the copy' });
assert.ok(out, 'the fire applied');
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).projects.stack.agents?.rev, undefined, 'the fire is written');
assert.ok(f.killed().includes(r.run), 'the run was told to stop before the write');
await assertRevoked(f, r, { cause: 'retire', code: 'copy-unavailable' });
});
test('CB4 a project copy removed from its project while the bus runs (no reload): the next call refuses and the binding is revoked', async (t) => {
const w = await world(t);
const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'research' } } });
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']);
const r = await f.launch('run-1');
assert.equal((await f.use(r.cap)).status, 200);
// The agent leaves the project through the writer alone: the broker never reloads, so only the
// gate's live check sees the copy gone.
f.writer.setProjectAgent(f.id, { revision: f.writer.read(f.id).revision, project: 'stack', name: 'rev', entry: null }, 'jason');
await assert.rejects(f.use(r.cap), refuses('copy-unavailable'));
await assertRevoked(f, r, { cause: 'retire', code: 'copy-unavailable' });
});
test('CB4 the finish keeps a remote revoke an earlier pass recorded: a later absent never replaces it', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
f.ack = false; f.stopMs = 200;
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
const op = f.rec().revoke.id;
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
// A later pass that saw the token already gone reports absent; the writer keeps revoked.
const opts = { actor: 'bus', owner: true, definitions: f.snapshot.definitions ?? {} };
f.writer.revokeCopyBinding(f.id, { phase: 'finish', instance: f.name, opId: op, steps: { stop: 'failed', slot: 'absent', remote: { gitea: 'absent' } } }, opts);
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
f.writer.revokeCopyBinding(f.id, { phase: 'finish', instance: f.name, opId: op, steps: { stop: 'confirmed', slot: 'absent', remote: { gitea: 'absent' } } }, opts);
assert.ok(tombstoned(f));
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
});
test('CB4 name collision: a project agent now claims the copy\'s instance name; the call refuses copy-refused and the binding is revoked', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w, { name: 'rev.stack' });
f.writer.setProjectAgent(f.id, { revision: f.writer.read(f.id).revision, project: 'stack', name: 'rev', entry: { definition: 'research' } }, 'jason');
await assert.rejects(f.use(r.cap), refuses('copy-refused'));
await assertRevoked(f, r, { cause: 'collision', code: 'copy-refused' });
});
test('CB4 shadowing: a named instance over a former copy\'s slot revokes the copy; nothing issues to the name until the tombstone', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
// Hold the stop so the revoke stays pending while the named instance asks for its slot.
f.ack = false; f.stopMs = 60000;
f.edit((d) => { d.roles[f.name] = { definition: 'research', credentials: { gitea: d.roles.coder.credentials.gitea } }; });
f.revoker.sweep(f.id);
await until(() => f.rec().state === 'revoking', 'revoking record');
assert.equal(f.rec().revoke.cause, 'shadowed');
assert.ok(f.revoker.pending(f.id, f.name));
f.reload();
await assert.rejects(f.call(f.human, 'credentials.issue', { agent: f.name, service: 'gitea', date: ahead(30), token: 'a'.repeat(40) }), refuses('credential-revoking'));
await assert.rejects(f.use(r.cap), (e) => e instanceof BusError);
// The launcher acknowledges the kill: the revoke finishes and the name is free.
for (const run of f.killed()) f.b.endLaunch({ business: f.id, run, reason: 'killed' });
await until(() => tombstoned(f), 'shadowed tombstone');
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
assert.ok(!existsSync(f.slot));
assert.deepEqual(f.bot.tokens, []);
assert.equal(f.revoker.pending(f.id, f.name), false);
// The issue now takes the named instance's ordinary path (this fixture's verbs answer unknown).
await assert.rejects(f.call(f.human, 'credentials.issue', { agent: f.name, service: 'gitea', date: ahead(30), token: 'a'.repeat(40) }), refuses('credential-unknown'));
});
test('CB4 an unacknowledged stop keeps the copy fenced and the record revoking; resume after the ack finishes the same op', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
f.ack = false; f.stopMs = 200;
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
const rec = f.rec();
assert.equal(rec.state, 'revoking');
assert.deepEqual(rec.revoke.failed, ['stop']);
// The remote step ran (the gate was closed and drained) and its definite outcome is kept.
assert.deepEqual(rec.revoke.remote, { gitea: 'revoked' });
assert.deepEqual(f.bot.tokens, []);
const op = rec.revoke.id;
const deletes = w.api.calls().filter((c) => c.startsWith('DELETE')).length;
// Fenced: no call, no prepare, no launch.
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
await assert.rejects(f.use(r.cap), (e) => e instanceof BusError);
// The launcher's exit arrives; the next pass finishes the op.
f.b.endLaunch({ business: f.id, run: r.run, reason: 'killed' });
f.ack = true;
await f.revoker.resume(f.id);
assert.ok(tombstoned(f));
assert.equal(f.rec().revoke.id, op);
assert.equal(w.api.calls().filter((c) => c.startsWith('DELETE')).length, deletes, 'a revoked grant is never called again');
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
});
test('CB4 a remote revoke that fails keeps the copy fenced; the slot is gone; a later pass revokes it', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const unrule = w.api.rule((e) => e.method === 'DELETE' && e.path.includes('/tokens/'), { status: 500, json: { message: 'down' } });
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
assert.equal(f.rec().state, 'revoking');
assert.deepEqual(f.rec().revoke.failed, ['remote']);
assert.ok(!existsSync(f.slot));
assert.equal(f.bot.tokens.length, 1, 'never claimed revoked');
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
await assert.rejects(f.use(r.cap), (e) => e instanceof BusError);
unrule();
await f.revoker.resume(f.id);
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
});
test('CB4 an organization without a managed admin slot records not-managed and never claims the token revoked', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w, { admin: false });
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
assert.deepEqual(out.remote, { gitea: 'not-managed' });
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked', remote: 'not-managed' });
assert.equal(f.bot.tokens.length, 1);
});
test('CB4 an expired admin slot is a failed remote step, not not-managed: the copy stays fenced', async (t) => {
const w = await world(t);
const { f } = await live(t, w, { adminRotateBy: '2020-01-01' });
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
assert.deepEqual(f.rec().revoke.failed, ['remote']);
assert.equal(f.bot.tokens.length, 1);
});
// A crash at each point of the revoke, then a restart of the bus on the same files: the copy never
// gets its authority back, the pending op is never lost, and resume finishes it under the same id.
for (const point of REVOKE_POINTS) {
test(`CB4 crash at ${point}, then restart: no resurrected authority, no lost op`, async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
let fired = false;
f.hooks[point] = () => { if (!fired) { fired = true; throw new Error(`crash at ${point}`); } };
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }));
assert.ok(fired);
const crashed = f.rec();
// Nothing the crashed process holds counts: a new process starts on the files.
f.start();
await f.boot();
if (point === 'beforePublication') {
// Nothing was published: the binding is as it was, and the owner's verb answered an error.
assert.equal(crashed.state, 'bound');
assert.deepEqual(Object.keys(f.gate.bound()), [f.name]);
const again = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
assert.equal(again.state, 'unbound');
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
return;
}
assert.ok(['revoking', 'unbound'].includes(crashed.state), crashed.state);
const op = crashed.revoke.id;
assert.match(op, /\S/);
// No resurrected authority: the boot proves nothing, prepare and launch refuse, nothing is sent.
assert.deepEqual(f.gate.bound(), {});
const sent = f.sent.length;
await assert.rejects(f.prepare(), (e) => ['credential-revoking', 'credential-revoked', 'credential-revoke-failed'].includes(e.code));
assert.equal(f.sent.length, sent);
assert.throws(() => f.b.copySession(r.cap), (e) => e instanceof BusError);
// No lost op: resume finishes it under the same id.
await f.revoker.resume(f.id);
assert.ok(tombstoned(f), JSON.stringify(f.rec()));
assert.equal(f.rec().revoke.id, op);
// The token went at the service before the crash or at resume; either way it is gone now.
assert.ok(['revoked', 'absent'].includes(f.rec().revoke.remote.gitea), f.rec().revoke.remote.gitea);
assert.deepEqual(f.bot.tokens, []);
assert.ok(!existsSync(f.slot));
await assert.rejects(f.prepare(), refuses('credential-revoked'));
// A second restart finds nothing pending and changes nothing.
const done = readFileSync(f.file, 'utf8');
f.start();
await f.boot();
assert.deepEqual(await f.revoker.resume(f.id), []);
assert.equal(readFileSync(f.file, 'utf8'), done);
});
}
test('CB4 archive with no remote adapter: slot gone, the copy stays revoking and fenced, the receipt claims no revoke, and the bus finishes it after restore', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
assert.deepEqual(out.copies, [{ agent: f.name, state: 'revoking', slot: 'removed', remote: { gitea: 'not-attempted' } }]);
assert.equal(f.rec().state, 'revoking');
assert.equal(f.rec().revoke.cause, 'archive');
assert.deepEqual(f.rec().revoke.failed, ['remote']);
assert.ok(!existsSync(f.slot));
// Not attempted at the service: the token is still there and nothing says otherwise.
assert.equal(f.bot.tokens.length, 1);
const log = readFileSync(join(w.data, 'org-lifecycle.log'), 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual(log.at(-1).copies, out.copies);
// A bus still holding the old gate answers the organization's state.
await assert.rejects(f.use(r.cap), (e) => ['org-archived', 'run-ended'].includes(e.code));
await assert.rejects(f.prepare(), refuses('org-archived'));
await orgService.restoreOrg(f.ctx, f.id, { ifRevision: out.revision });
f.start();
await f.boot();
assert.deepEqual(f.gate.bound(), {});
await assert.rejects(f.prepare(), (e) => ['credential-revoking', 'credential-revoke-failed'].includes(e.code));
// The bus's resume at its start runs the remote step through the managed adapter.
await f.revoker.resume(f.id);
await assertRevoked(f, null, { cause: 'archive', code: 'credential-unbound' });
});
test('CB4 archive with the managed adapter wired revokes the token at Gitea', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
// The adapter the CLI and Console wire (org-bus.mjs copyRemoteFor), on the fake Gitea.
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'revoked' } }]);
assert.ok(tombstoned(f));
assert.deepEqual(f.bot.tokens, []);
});
test('CB4 archive refuses when a slot parent is not the stack user\'s private directory, and the slot stays', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
chmodSync(dirname(f.slot), 0o755);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
await assert.rejects(orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) }), refuses('credential-revoke-failed'));
assert.ok(existsSync(f.slot));
assert.equal(f.rec().state, 'revoking');
});
test('CB4 archive rerun after a crash joins the pending op', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
// The archive crashed after its first write.
const begun = f.writer.revokeCopyBinding(f.id, { phase: 'begin', instance: f.name, cause: 'archive' }, { actor: 'bus', owner: true });
assert.equal(begun.state, 'revoking');
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
assert.equal(out.copies[0].state, 'unbound');
assert.equal(f.rec().revoke.id, begun.op);
assert.ok(!existsSync(f.slot));
});
test('CB4 delete quarantines the copy slot and its receipt never claims a remote revoke; a recreated organization never resolves the old copy', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
assert.deepEqual(out.copies, [{ agent: f.name, service: 'gitea', slot: 'quarantined', remote: 'not-attempted' }]);
assert.match(out.note, /only where copies lists a copy with remote revoked/);
assert.ok(!existsSync(f.slot));
assert.equal(f.bot.tokens.length, 1);
await assert.rejects(f.use(r.cap), (e) => ['org-unavailable', 'run-ended'].includes(e.code));
await assert.rejects(f.prepare(), (e) => ['org-unavailable', 'organization-unreadable', 'credential-unavailable'].includes(e.code));
// Recreated under the same id: a new incarnation. The old gate still refuses.
const doc = { ...businessDoc(w.root, f.id), plugins: { harness: ['pi'] } };
writeJson(f.file, doc);
await reserveOrg(f.ctx, f.id); await activateOrg(f.ctx, f.id);
await assert.rejects(f.prepare(), (e) => ['org-unavailable', 'copy-unavailable', 'credential-unbound'].includes(e.code));
});
test('CB4 delete with the managed adapter revokes each copy token at Gitea and says so in the receipt', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
assert.deepEqual(out.copies, [{ agent: f.name, service: 'gitea', slot: 'quarantined', remote: 'revoked' }]);
assert.equal(out.copiesUnreadable, undefined);
assert.deepEqual(f.bot.tokens, []);
});
test('CB4 delete with an unreadable organization file says so, never an empty copy list', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
// The file fails the private read when the copies step reads it (busStop runs just before).
const busStop = async () => { chmodSync(f.file, 0o644); return { stopped: true, reason: 'stopped' }; };
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop });
assert.equal(out.copiesUnreadable, true);
assert.deepEqual(out.copies, []);
assert.match(out.note, /copiesUnreadable/);
});
test('CB4 an owner update cannot change the repository of a project a copy binding still grants', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const { revision } = (await listOrgs(f.ctx)).find((o) => o.id === f.id);
const doc = { ...JSON.parse(readFileSync(f.file, 'utf8')), human: f.ctx.principal.sub };
const moved = structuredClone(doc); moved.projects.stack.remote = `${w.api.url}/elsewhere/stack`;
await assert.rejects(orgService.updateOrg(f.ctx, f.id, moved, { ifRevision: revision }), refuses('project-repo-bound'));
// Removed (with another project left, so the document itself is valid).
const dropped = structuredClone(doc);
dropped.projects.other = { ...structuredClone(doc.projects.stack), root: join(w.data, 'org-runtime', f.id, 'projects', 'other'), remote: `${w.api.url}/owner/other` };
delete dropped.projects.other.agents; delete dropped.projects.stack;
await assert.rejects(orgService.updateOrg(f.ctx, f.id, dropped, { ifRevision: revision }), refuses('project-repo-bound'));
// An update that leaves the bound project's repository as it is still goes through.
const other = structuredClone(doc); other.vars = { ...other.vars, 'bus.tester': false };
await orgService.updateOrg(f.ctx, f.id, other, { ifRevision: revision });
});
test('CB4 the owner\'s project.remote.set cannot change the repository of a project a copy binding still grants; the revoke then finishes at Gitea', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w, { gitOnly: true });
const { revision } = f.writer.read(f.id);
assert.throws(() => f.writer.setProjectRemote(f.id, { revision, project: 'stack', remote: `${w.api.url}/owner/elsewhere` }, 'jason'), refuses('project-repo-bound'));
assert.equal(f.writer.read(f.id).revision, revision);
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
assert.equal(out.state, 'unbound');
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked' });
});
test('CB4 a remote step whose bound server cannot be read, or whose record names another incarnation, fails and the copy stays fenced', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const rec = f.rec();
const base = { grant: f.grant, read: (x) => f.writer.read(x).document };
// The bound project is not in the file any more: no origin to revoke against, never not-managed.
const gone = giteaCopyRemote({ ...base, read: (x) => { const d = structuredClone(f.writer.read(x).document); delete d.projects.stack; return d; } });
assert.equal(await gone(f.id, f.name, rec, 'gitea'), 'failed');
// Another incarnation, or none: the admin slot is never asked to delete.
const sent = f.api.calls().length;
for (const incarnation of [() => 'f'.repeat(32), () => null, () => { throw new Error('unreadable'); }])
assert.equal(await giteaCopyRemote({ ...base, incarnation })(f.id, f.name, rec, 'gitea'), 'failed');
assert.equal(f.bot.tokens.length, 1);
assert.equal(f.api.calls().length, sent, 'no request reached the server');
// Through the revoker: the record stays revoking with the remote step failed, and fenced.
const revoker = copyRevoker({ writer: f.writer, gate: f.gate, credentials: f.credentials, stopRuns: (...a) => f.stopRuns(...a),
remote: gone, definitions: () => f.snapshot.definitions ?? {} });
const done = await revoker.run(f.id, f.name, { cause: 'revoke', actor: 'jason', owner: true });
assert.equal(done.state, 'revoking');
assert.deepEqual(f.rec().revoke.failed, ['remote']);
assert.equal(f.bot.tokens.length, 1);
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
});
// Build review P3-4: when the begin write refuses before any change, only an owner's verb on a copy
// that was open reopens it (to its parked proof); a loss the bus found, or a copy already closed,
// stays closed.
test('CB4 a refused begin reopens the gate only for an owner verb on an open copy; a loss the bus found stays closed', async () => {
const run = async ({ cause, closed }) => {
const calls = [];
const gate = { closed: () => closed, close: (b, i, c) => calls.push(['close', i, c ?? null]), open: (b, i, parked) => calls.push(['open', i, parked === true]), drain: async () => {} };
const writer = { read: () => ({ document: { copyBindings: {} } }), revokeCopyBinding: () => { throw new BusError('org-mismatch'); } };
const stopRuns = async () => { throw new Error('stop must not run'); };
const revoker = copyRevoker({ writer, gate, credentials: { discardCopy: () => { throw new Error('slot must not go'); } }, stopRuns });
await assert.rejects(revoker.run('acme', 'rev', { cause, owner: cause === 'revoke' }), { code: 'org-mismatch' });
return calls;
};
for (const cause of ['expired', 'unavailable', 'retire', 'shadowed', 'upstream'])
assert.deepEqual(await run({ cause, closed: false }), [['close', 'rev', null]], cause);
assert.deepEqual(await run({ cause: 'revoke', closed: true }), [['close', 'rev', null]]);
assert.deepEqual(await run({ cause: 'revoke', closed: false }), [['close', 'rev', null], ['open', 'rev', true]]);
});
test('CB4 resume keeps a failed pass closed with its failure code while it runs again', async () => {
const calls = [];
const gate = { closed: () => true, close: (b, i, c) => calls.push([i, c ?? null]), open: () => calls.push(['open']), drain: async () => {} };
const copyBindings = { rev: { state: 'revoking', revoke: { id: 'op-1', cause: 'expired', at: 'x', failed: ['remote'] } }, two: { state: 'revoking', revoke: { id: 'op-2', cause: 'expired', at: 'x' } } };
const writer = { read: () => ({ document: { copyBindings } }), revokeCopyBinding: () => { throw new BusError('org-mismatch'); } };
const revoker = copyRevoker({ writer, gate, credentials: { discardCopy: () => 'absent' }, stopRuns: async () => ({ confirmed: true }) });
await revoker.resume('acme');
assert.deepEqual(calls.slice(0, 2), [['rev', 'credential-revoke-failed'], ['two', null]]);
assert.ok(!calls.some((c) => c[0] === 'open'));
});
test('CB4 a second organization launches and calls throughout the first one\'s revoke, archive and delete', async (t) => {
const w = await world(t);
const { f: demo, r: dr } = await live(t, w);
const { f: acme, r: ar } = await live(t, w, { id: 'acme' });
const ok = async (label) => {
assert.equal((await acme.use(ar.cap)).status, 200, label);
const fresh = await acme.launch(`acme-${label}`);
assert.equal((await acme.use(fresh.cap)).status, 200, label);
};
// During the revoke: hold demo's stop and call acme meanwhile.
demo.ack = false; demo.stopMs = 60000;
const revoke = demo.call(demo.human, 'credentials.revoke', { agent: demo.name, service: 'gitea', reason: 'Owner revoke' });
await until(() => demo.rec().state === 'revoking', 'demo revoking');
await ok('during-revoke');
demo.b.endLaunch({ business: demo.id, run: dr.run, reason: 'killed' });
assert.equal((await revoke).state, 'unbound');
await ok('after-revoke');
// Archive and delete demo.
let revision = (await listOrgs(demo.ctx)).find((o) => o.id === 'demo').revision;
const archived = await orgService.archiveOrg(demo.ctx, 'demo', { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
await ok('after-archive');
await orgService.deleteOrg(demo.ctx, 'demo', { confirm: 'demo', ifRevision: archived.revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
await ok('after-delete');
assert.equal(acme.rec().state, 'bound');
assert.equal(acme.bot.tokens.length, 1);
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' });
});
// #1873 CB5 re-check: a copy whose role needs a service a copy cannot bind reads
// copy-service-unbindable, but a revoke of a binding it was issued anyway outranks that.
test('CB5 status: copy-service-unbindable never hides a revoke that is running or failed', async (t) => {
const w = await world(t);
const f = await org(t, w);
const unbindable = { state: 'unbound', missing: ['gitea', 'vikunja'] };
assert.equal(f.gate.status('demo', f.name, unbindable).status, 'copy-service-unbindable');
let release;
const held = f.gate.fence('demo', f.name, () => new Promise((r) => { release = r; }));
assert.deepEqual(f.gate.status('demo', f.name, unbindable), { status: 'credential-revoking', next: COPY_BINDING_STATUS['credential-revoking'] });
release();
await held;
f.gate.close('demo', f.name, 'credential-revoke-failed');
assert.deepEqual(f.gate.status('demo', f.name, unbindable), { status: 'credential-revoke-failed', next: COPY_BINDING_STATUS['credential-revoke-failed'] });
});