// #1873 CB3 (design section 5 steps 7 and 8, section 8 row CB3): a bound agent copy launches only // when the bus's boot proved its binding live at the service, every launch carries a launch // authority the bus checks again at prepare, final admission and bindLaunch, and each credential // call is checked against the session's authority before and after the request. The fake Gitea is // fake-gitea-api.mjs on 127.0.0.1; the runtime and launcher halves are runtime tests below and // packages/cli/tests/agent-copies.test.mjs. import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { 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 { 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, reserveOrg } from '../../organization/src/org-registry.mjs'; import { copyBotLogin } from '../../organization/src/copy-bindings.mjs'; import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs'; const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' }; const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } }; const WRITE = { gitea: { scopes: ['read:user', 'write:repository'], projects: ['stack'] } }; const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); const CODES = ['restart-required', 'credential-revoked', 'credential-revoking', 'credential-unavailable', 'credential-expired', 'copy-authority-changed', 'copy-request-refused', 'copy-redirect-refused', 'organization-changed', 'organization-unreadable']; // demo with the project stack on the fake Gitea and the root copy rev (research, which needs gitea // alone; a binding may be narrower than the role). The // owner binds rev with `services` and, unless `issue` is false, issues one live token for it (CB2). // `gate` is the CB3 gate on the boot snapshot; `boot()` runs its boot check and returns the broker's // business with the copies it proved promoted, as runtime.mjs does. async function fixture(t, { services = READ, perm = 'read', issue = true, definition = 'research', budgets = {} } = {}) { let clock = Date.now(); const root = mkdtempSync(join(tmpdir(), 'cb3-')), rolesDir = rolesCopy(root); t.after(() => rmSync(root, { recursive: true, force: true })); const api = await fakeGiteaApi(t, root); const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } }; doc.projects.stack.remote = `${api.url}/owner/stack`; const file = writeJson(join(root, 'businesses/demo.json'), doc); const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: root, dataRoot, instance: 'test' }; await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo'); const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition } } }, { rolesDir }) }, rolesDir }); const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot); t.after(() => store.close()); const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null }); const storeRoot = join(root, 'secrets', 'agents'); mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700); const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' }); // Every request the gate sends, with what the test lets through; `hold` parks one until released. const sent = []; const transport = async (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); }; const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(clock), ...budgets }); const advance = (days) => { clock += days * 86400000; }; let promoted = snapshot; const business = () => { const d = loadWithCopies('demo', { 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: () => ['demo'], ca: api.ca, timeoutMs: 3000 })(...a); const b = new Broker({ store, businesses: { demo: business() }, copyLaunch: gate, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } }, credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issueGrant(...a) }); const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args })); const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.rev; const repo = api.addRepo('owner', 'stack'); api.addRepo('owner', 'other'); await call(owner, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services } }); const r = rec(); const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea'); const bot = api.addUser(login).user; repo.collaborators.push(login); repo.permissions[login] = perm; const scopes = services.gitea.scopes; const reissue = (scopesNow = scopes) => call(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token: api.addToken(login, `t${api.issued.length}`, scopesNow).sha1 }); if (issue) await reissue(); const boot = async () => { await gate.boot('demo'); promoted = promoteCopies(snapshot, gate.bound()); b.reloadBusiness('demo', business()); }; const slot = join(storeRoot, 'demo', 'rev', 'gitea'); // A run of rev, bound with the authority the launcher's prepare returned. // rev is a thread instance, so each run takes a thread of its own in the stack project. let project = null; const threadFor = async (run) => { project ??= (await call(human, 'project.create', { name: 'stack work', repo: 'stack' })).id; return (await call(human, 'thread.create', { project, name: run, instance: 'rev' })).id; }; const launch = async (run = 'rev-run') => { const thread = await threadFor(run); const revision = writer.read('demo').revision; const { authority } = await gate.prepare('demo', 'rev', revision); return { authority, thread, cap: b.bindLaunch({ business: 'demo', role: 'rev', run, harness: 'pi', thread, copyAuthority: authority, revision }) }; }; const use = (cap, request) => Promise.resolve().then(() => gate.call(b.copySession(cap), request)); const GET = { method: 'GET', path: '/api/v1/repos/owner/stack' }; // The organization file edited in place, as an owner's editor would. const edit = (fn) => { const d = JSON.parse(readFileSync(file, 'utf8')); fn(d); writeFileSync(file, JSON.stringify(d, null, 2) + '\n'); }; // The next request matching `match` waits at the fake server until release() lets it through. const 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 }; }; // Gate requests to the copy's repositories (the proof's /version, /token and /user routes aside). const repoCalls = () => sent.filter((x) => x.includes('/api/v1/repos/')).length; return { root, rolesDir, edit, hold, advance, repoCalls, file, api, b, gate, threadFor, writer, credentials, owner, human, call, rec, bot, login, repo, reissue, boot, slot, launch, use, sent, GET, storeRoot, snapshot, business }; } const refuses = (code) => (e) => { assert.ok(e instanceof BusError, String(e?.stack ?? e)); assert.equal(e.code, code); return true; }; test('CB3 every new 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`); } }); test('CB3 a copy bound before boot is proved live at boot and launches; prepare, admission and bindLaunch carry one authority', async (t) => { const f = await fixture(t); assert.equal(f.rec().state, 'bound'); await f.boot(); assert.deepEqual(f.gate.bound(), { rev: ['gitea'] }); const role = f.b.taskRoles('demo').rev; assert.deepEqual([role.copyBound, role.credentials], [['gitea'], {}]); const { authority, cap } = await f.launch(); assert.match(authority, /^[0-9a-f]{64}$/); assert.equal(f.gate.authority('demo', 'rev').authority, authority); // The live proof at prepare went to the bound origin as the copy's account. assert.ok(f.sent.includes('GET /api/v1/token')); const r = await f.use(cap, f.GET); assert.equal(r.status, 200); assert.equal(r.json.full_name, 'owner/stack'); assert.ok(!JSON.stringify(r).includes(f.api.issued.at(-1))); // bindLaunch refuses a bound copy without the authority, or with another one. const r2 = await f.threadFor('r2'), r3 = await f.threadFor('r3'); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2 }), refuses('copy-authority-changed')); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r3', harness: 'pi', thread: r3, copyAuthority: 'f'.repeat(64) }), refuses('copy-authority-changed')); // A non-copy never carries one. assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', copyAuthority: authority }), refuses('invalid-request')); }); test('CB3 a binding approved after boot answers restart-required at every launch step and sends nothing', async (t) => { const f = await fixture(t, { issue: false }); assert.notEqual(f.rec().state, 'bound'); await f.boot(); assert.deepEqual([f.gate.bound(), f.sent], [{}, []]); // The owner issues the grant now: the record is bound and granted, but the boot didn't prove it. await f.reissue(); assert.equal(f.rec().state, 'bound'); assert.equal(f.b.taskRoles('demo').rev, undefined); assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required')); await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('restart-required')); assert.throws(() => f.gate.admit('demo', 'rev', 'f'.repeat(64)), refuses('restart-required')); // The verb says so too, before its own unbound refusal. await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('restart-required')); assert.deepEqual(f.sent, []); }); test('CB3 a partial binding (a role needing gitea and vikunja, bound for gitea alone) never launches and its boot sends nothing', async (t) => { const f = await fixture(t, { definition: 'reviewer' }); assert.equal(f.rec().state, 'bound'); await f.boot(); assert.deepEqual([f.gate.bound(), f.sent], [{}, []]); assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-scope-unverified')); await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-scope-unverified')); await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('credential-scope-unverified')); assert.deepEqual(f.sent, []); }); test('CB3 a redirect from the bound origin is refused and never followed', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); f.api.rule((e) => e.path === '/api/v1/repos/owner/stack', { status: 302 }); await assert.rejects(f.use(cap, f.GET), refuses('copy-redirect-refused')); assert.equal(f.api.requests.filter((e) => e.path.startsWith('/api/v1/repos/')).length, 1); }); test('CB3 a call reaches only the admitted repositories, by the methods the binding allows, with no body on a read', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); for (const request of [ { method: 'GET', path: '/api/v1/repos/owner/other' }, { method: 'GET', path: '/api/v1/repos/owner/stack/../other' }, { method: 'GET', path: '/api/v1/repos/owner/stack/%2e%2e/other' }, { method: 'GET', path: '/api/v1/user' }, { method: 'GET', path: 'https://evil.test/api/v1/repos/owner/stack' }, { method: 'GET', path: '//evil.test/api/v1/repos/owner/stack' }, { method: 'GET', path: '/api/v1/repos/owner/stack', body: { x: 1 } }, { method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } }, { method: 'TRACE', path: '/api/v1/repos/owner/stack' }, { service: 'vikunja', method: 'GET', path: '/api/v1/repos/owner/stack' }, ]) await assert.rejects(f.use(cap, request), refuses('copy-request-refused'), JSON.stringify(request)); assert.equal(f.repoCalls(), 0); // The session, never the arguments, names who the call is for. await assert.rejects(f.gate.call({ business: 'demo', role: 'rev', copyAuthority: 'f'.repeat(64) }, f.GET), refuses('copy-authority-changed')); assert.throws(() => f.b.copySession(f.human), refuses('copy-request-refused')); assert.equal(f.repoCalls(), 0); }); test('CB3 a write binding reaches its repository by POST as the copy\'s account (a READ binding is refused above)', async (t) => { const f = await fixture(t, { services: WRITE, perm: 'write' }); await f.boot(); const { cap } = await f.launch(); f.api.rule((e) => e.method === 'POST' && e.path === '/api/v1/repos/owner/stack/issues', { status: 201, json: { number: 1 } }); const r = await f.use(cap, { method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } }); assert.deepEqual(r, { status: 201, json: { number: 1 } }); const seen = f.api.requests.filter((e) => e.path === '/api/v1/repos/owner/stack/issues'); assert.deepEqual(seen.map((e) => [e.method, e.as]), [['POST', f.login]]); }); test('CB3 a re-issue, a replaced slot or rewritten token bytes deny the next call with no request and no cached token', async (t) => { for (const [name, change, code] of [ ['re-issue', (f) => f.reissue(), 'restart-required'], ['slot replaced', (f) => { writeFileSync(f.slot + '.n', 'x'.repeat(40), { mode: 0o600 }); renameSync(f.slot + '.n', f.slot); }, 'credential-unavailable'], ['bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40))), 'credential-unavailable'], ['slot removed', (f) => unlinkSync(f.slot), 'credential-unavailable'], ['mode widened', (f) => chmodSync(f.slot, 0o644), 'credential-unavailable'], ]) { await t.test(name, async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); assert.equal((await f.use(cap, f.GET)).status, 200); const before = f.sent.length; await change(f); // The bus's session lookup refuses the run itself, before the gate's own check. assert.throws(() => f.b.copySession(cap), refuses(code)); await assert.rejects(f.use(cap, f.GET), refuses(code)); // No new launch either. await assert.rejects(f.gate.prepare('demo', 'rev'), refuses(code)); assert.deepEqual(f.sent.slice(before), []); }); } }); test('CB3 a revoke while a call is in flight denies that call\'s answer and the next call', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack'); const pending = f.use(cap, f.GET); await h.arrived; await f.reissue(); h.release(); await assert.rejects(pending, refuses('restart-required')); const before = f.sent.length; await assert.rejects(f.use(cap, f.GET), refuses('restart-required')); assert.equal(f.sent.length, before); }); test('CB3 a grant change in flight fences the copy: nothing is admitted or sent until it ends, and an unknown outcome keeps the fence', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); const r2 = await f.threadFor('r2'); const authority = f.gate.authority('demo', 'rev').authority; // The owner's re-issue holds at its live proof; the copy is fenced meanwhile. const h = f.hold((e) => e.path === '/api/v1/version'); const issuing = f.reissue(); await h.arrived; const before = f.sent.length; assert.equal(f.gate.revoking('demo', 'rev'), true); assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoking')); assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoking')); await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoking')); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2, copyAuthority: authority }), refuses('credential-revoking')); await assert.rejects(f.use(cap, f.GET), refuses('credential-revoking')); assert.equal(f.sent.length, before); h.release(); await issuing; assert.equal(f.gate.revoking('demo', 'rev'), false); assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required')); // A definite refusal lifts the fence; an unknown outcome, a system error or a codeless throw keeps it. await assert.rejects(f.gate.fence('demo', 'rev', () => { throw new BusError('credential-date'); }), refuses('credential-date')); assert.equal(f.gate.revoking('demo', 'rev'), false); for (const thrown of [new BusError('outcome-unknown'), Object.assign(new Error('disk'), { code: 'ENOSPC' }), new Error('bug')]) { const g = await fixture(t); await g.boot(); await assert.rejects(g.gate.fence('demo', 'rev', () => { throw thrown; })); assert.equal(g.gate.revoking('demo', 'rev'), true, String(thrown.code)); await assert.rejects(g.gate.prepare('demo', 'rev'), refuses('credential-revoking')); assert.deepEqual(g.sent.filter((x) => !x.startsWith('GET /api/v1/version') && !x.startsWith('GET /api/v1/token') && !x.startsWith('GET /api/v1/user')), []); } }); // Each change lands while prepare's live proof is at the server (between the authority before and // after), then again between prepare and bindLaunch, then between bindLaunch and the first use. None // may end in a request to the copy's repositories. const RACES = [ ['role definition changed', (f) => writeFileSync(join(f.rolesDir, 'research.md'), readFileSync(join(f.rolesDir, 'research.md'), 'utf8') + '\nChanged.\n')], ['binding generation moved', (f) => f.edit((d) => { d.copyBindings.rev.generation += 1; })], ['binding unbound', (f) => f.edit((d) => { d.copyBindings.rev.state = 'unbound'; })], ['grant scopes narrowed', (f) => f.edit((d) => { d.copyBindings.rev.services.gitea.scopes = ['read:user']; })], ['token bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40)))], ['project remote moved', (f) => f.edit((d) => { d.projects.stack.remote = 'https://elsewhere.test/owner/stack'; })], ['project removed', (f) => f.edit((d) => { delete d.projects.stack; })], ['project excludes the copy', (f) => f.edit((d) => { d.projects.stack.agents = { ...d.projects.stack.agents, rev: { exclude: true } }; })], ]; test('CB3 a change during prepare\'s live proof refuses the launch with no repository request', async (t) => { for (const [name, change] of RACES) { await t.test(name, async (t) => { const f = await fixture(t); await f.boot(); const h = f.hold((e) => e.path === '/api/v1/token'); const preparing = f.gate.prepare('demo', 'rev'); await h.arrived; await change(f); h.release(); await assert.rejects(preparing, (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code)); assert.equal(f.repoCalls(), 0); }); } }); test('CB3 a change between prepare and bindLaunch, or between bindLaunch and use, refuses with no repository request', async (t) => { for (const [name, change] of RACES) { await t.test(`${name} before bind`, async (t) => { const f = await fixture(t); await f.boot(); const thread = await f.threadFor('r1'); const { authority } = await f.gate.prepare('demo', 'rev'); await change(f); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority }), (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code)); assert.equal(f.repoCalls(), 0); }); await t.test(`${name} before use`, async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); await change(f); await assert.rejects(f.use(cap, f.GET), (e) => e instanceof BusError && /^(copy-|credential-|restart-|run-)/.test(e.code)); assert.equal(f.repoCalls(), 0); }); } }); // The organization's registry entry rewritten in place (mode kept), as a lifecycle change would. const registryEdit = (f, fn) => { const file = join(f.root, 'org-runtime.json'); const d = JSON.parse(readFileSync(file, 'utf8')); fn(d.orgs.demo, d); writeFileSync(file, JSON.stringify(d, null, 2) + '\n'); }; test('CB3 an organization no longer active admits and sends nothing, with the binding and incarnation unchanged', async (t) => { for (const state of ['archived', 'deleting', 'reserved']) { await t.test(`${state} after launch`, async (t) => { const f = await fixture(t); await f.boot(); const { cap, authority } = await f.launch(); registryEdit(f, (e) => { e.state = state; }); const before = f.sent.length; assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoked')); assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked')); await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked')); await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked')); assert.equal(f.sent.length, before); assert.equal(f.repoCalls(), 0); }); } await t.test('archived while prepare\'s proof is at the server', async (t) => { const f = await fixture(t); await f.boot(); const h = f.hold((e) => e.path === '/api/v1/token'); const preparing = f.gate.prepare('demo', 'rev'); await h.arrived; registryEdit(f, (e) => { e.state = 'archived'; }); h.release(); await assert.rejects(preparing, refuses('credential-revoked')); assert.equal(f.repoCalls(), 0); }); await t.test('archived before boot', async (t) => { const f = await fixture(t); registryEdit(f, (e) => { e.state = 'archived'; }); await f.boot(); assert.deepEqual([f.gate.bound(), f.sent], [{}, []]); }); await t.test('registry unsafe after launch', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); chmodSync(join(f.root, 'org-runtime.json'), 0o644); const before = f.sent.length; await assert.rejects(f.use(cap, f.GET), refuses('credential-unavailable')); assert.equal(f.sent.length, before); }); }); test('CB3 an organization recreated under the same id (a new incarnation) admits and sends nothing for the old copy', async (t) => { const f = await fixture(t); await f.boot(); const { cap, authority } = await f.launch(); registryEdit(f, (e) => { e.incarnation = 'f'.repeat(32); }); const before = f.sent.length; assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked')); await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked')); await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked')); assert.equal(f.sent.length, before); }); // The project points at another repository on the same server: origin, binding and slot are as // boot proved them, only the repositories the launch authority covers move. const repointed = (f) => f.edit((d) => { d.projects.stack.remote = d.projects.stack.remote.replace('/owner/stack', '/owner/other'); }); test('CB3 a change while a call is in flight denies that call\'s answer, including one that moves only the launch authority', async (t) => { for (const [name, change, code] of [['repository repointed', repointed, 'credential-revoked'], ...RACES.filter(([n]) => ['role definition changed', 'project excludes the copy'].includes(n)).map(([n, c]) => [n, c, null])]) { await t.test(name, async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack'); const pending = f.use(cap, f.GET); await h.arrived; await change(f); h.release(); await assert.rejects(pending, code ? refuses(code) : (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code)); }); } }); test('CB3 a running copy session whose launch authority moved answers no agent verb', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); // Past the copy check to the holder check: the session holds no claim yet. await assert.rejects(f.call(cap, 'inbox'), refuses('not-holder')); repointed(f); assert.doesNotThrow(() => f.gate.authority('demo', 'rev')); await assert.rejects(f.call(cap, 'inbox'), refuses('copy-authority-changed')); }); test('CB3 a binding with no repository scope sends no repository request, read or write', async (t) => { const f = await fixture(t, { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } }); await f.boot(); assert.deepEqual(f.gate.bound(), { rev: ['gitea'] }); const { cap } = await f.launch(); for (const method of ['GET', 'HEAD', 'POST']) await assert.rejects(f.use(cap, { method, path: '/api/v1/repos/owner/stack' }), refuses('copy-request-refused'), method); assert.equal(f.repoCalls(), 0); }); test('CB3 the organization file revision the launch resolved holds through prepare, final admission and bindLaunch', async (t) => { // Same document, other bytes: the launch authority is unchanged, only the revision moves. const rewrite = (f) => writeFileSync(f.file, JSON.stringify(JSON.parse(readFileSync(f.file, 'utf8'))) + '\n'); await t.test('changed before prepare', async (t) => { const f = await fixture(t); await f.boot(); const revision = f.writer.read('demo').revision; rewrite(f); const before = f.sent.length; await assert.rejects(f.gate.prepare('demo', 'rev', revision), refuses('organization-changed')); assert.equal(f.sent.length, before); }); await t.test('changed during prepare\'s proof', async (t) => { const f = await fixture(t); await f.boot(); const revision = f.writer.read('demo').revision; const h = f.hold((e) => e.path === '/api/v1/token'); const preparing = f.gate.prepare('demo', 'rev', revision); await h.arrived; rewrite(f); h.release(); await assert.rejects(preparing, refuses('organization-changed')); }); await t.test('changed after final admission, before bindLaunch', async (t) => { const f = await fixture(t); await f.boot(); const thread = await f.threadFor('r1'); const revision = f.writer.read('demo').revision; const { authority } = await f.gate.prepare('demo', 'rev', revision); f.gate.admit('demo', 'rev', authority, revision); rewrite(f); assert.equal(f.gate.authority('demo', 'rev').authority, authority); assert.throws(() => f.gate.admit('demo', 'rev', authority, revision), refuses('organization-changed')); const bind = (extra) => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority, ...extra }); assert.throws(() => bind({ revision }), refuses('organization-changed')); assert.throws(() => bind({}), refuses('organization-changed')); assert.equal(f.repoCalls(), 0); // The same authority on the file as it stands now binds: the refusal above was the revision's. assert.ok(bind({ revision: f.writer.read('demo').revision })); }); await t.test('a non-copy never carries a revision', async (t) => { const f = await fixture(t); await f.boot(); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', revision: f.writer.read('demo').revision }), refuses('invalid-request')); }); }); test('CB3 a copy past its rotation date refuses credential-expired at prepare and at the next call', async (t) => { const f = await fixture(t); await f.boot(); const { cap } = await f.launch(); f.advance(31); const before = f.sent.length; await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-expired')); await assert.rejects(f.use(cap, f.GET), refuses('credential-expired')); assert.equal(f.sent.length, before); }); test('CB3 a boot that cannot verify proves nothing: the service unreachable, the token refused, the slot missing', async (t) => { for (const [name, setup, code] of [ // The verifier answers an unreachable service as unverified (copy-grant.mjs verifyGiteaToken). ['service down', (f) => f.api.rule(() => true, { drop: true }), 'credential-scope-unverified'], ['token revoked at the service', (f) => f.api.rule((e) => e.path === '/api/v1/token', { status: 401, json: { message: 'no' } }), 'credential-scope-unverified'], ['slot missing', (f) => unlinkSync(f.slot), 'credential-unavailable'], ]) { await t.test(name, async (t) => { const f = await fixture(t); setup(f); await f.boot(); assert.deepEqual(f.gate.bound(), {}); assert.throws(() => f.gate.authority('demo', 'rev'), refuses(code)); await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses(code)); assert.equal(f.repoCalls(), 0); }); } }); // The launcher's host stops on a reply later than 10 s, so a slow service must not hold prepare past // its budget: the proof gets no answer, the launch is refused, and nothing more is sent. test('CB3: a service that does not answer within the budget fails prepare and boot closed, inside the budget', { timeout: 60000 }, async (t) => { await t.test('prepare', async (t) => { const f = await fixture(t, { budgets: { prepareMs: 400 } }); await f.boot(); const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true }); t.after(unrule); const at = Date.now(); await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified'); assert.ok(Date.now() - at < 2500, `prepare took ${Date.now() - at} ms`); assert.equal(f.repoCalls(), 0); }); await t.test('boot', async (t) => { const f = await fixture(t, { budgets: { bootMs: 400 } }); const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true }); t.after(unrule); const at = Date.now(); await f.boot(); assert.ok(Date.now() - at < 2500, `boot took ${Date.now() - at} ms`); assert.deepEqual(f.gate.bound(), {}); await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified'); }); });