import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs'; // Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials // after the writer's checks and before the write. Fire revokes each service and keeps the store // directory. Delete calls remove (sage-1593). A failed revoke stops the verb with // credential-revoke-failed and the business file's bytes unchanged. Fake tokens in a scratch root. import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { busOrganization } from '../src/organization-context.mjs'; import { startBroker } from '../src/runtime.mjs'; import { Store } from '../src/store.mjs'; import { hostGrantRecord, hostRecord } from './host-fixtures.mjs'; import { loadBusiness } from '../../organization/src/organization-config.mjs'; import { resolveInstance } from '../../organization/src/resolve.mjs'; import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; const TOKEN = 'fake_issued_token_0123456789'; const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotateBy' : 'expires']: FAR_DATE }); // coder: gitea in the store, vikunja a file reference. rev-2 and rev-3: both in the store, with a // tracker bot. `drop` names instances whose credentials the boot copy leaves out, as a restart that // dropped the entry would: the running bus then has no entry for a file on disk (sage-1592). async function setup(t, { drop = [], seed = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-lifecycle-revoke-')); const rolesDir = rolesCopy(root); const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } }; doc.roles.coder.credentials.gitea = store('coder', 'gitea'); for (const [name, botId] of [['rev-2', 40], ['rev-3', 41]]) { doc.roles[name] = { definition: 'reviewer', tracker: { bot: `bot-demo-${name}`, botId }, credentials: { gitea: store(name, 'gitea'), vikunja: store(name, 'vikunja') } }; } doc.launch.instances.push('rev-2', 'rev-3'); const file = writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 }); // Row 281 T1: records written before the start, as an earlier bus left them. if (seed) { const seeded = new Store(join(root, 'data')); seed(seeded); seeded.close(); } const b = loadBusiness('demo', { dir: root, rolesDir }); const resolved = {}; for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i }); const boot = busOrganization(b, resolved); for (const name of drop) delete boot.roles[name].credentials; const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: boot }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); chmodSync(join(root, 'secrets', 'agents'), 0o700); rmSync(root, { recursive: true, force: true }); }); const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); bus.broker.request(chief, { verb: 'role.claim' }); const ask = (verb, args) => bus.broker.request(human, { verb, args }); const hireConsole=fixtureConsole(bus.broker); const fire = name => approveFixtureFire(bus.broker,hireConsole,name); const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent); const plant = (agent, service) => { mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 }); writeFileSync(join(agentDir(agent), service), TOKEN + '\n', { mode: 0o600 }); }; // A delete runs as the launcher's pass would: the verb's request, the take, then lifecycleDelete. // A refused delete closes the request with its code, as the launcher does. const remove = (instance) => { const { request } = ask('instances.set', { revision: ask('instances').revision, instance, op: 'delete' }); assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true); try { return { request, ...bus.lifecycleDelete({ business: 'demo', request }) }; } catch (e) { bus.lifecycleDone({ business: 'demo', request, refusal: e.code }); throw e; } }; const bytes = () => readFileSync(file, 'utf8'); const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; }; const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state])); return { root, bus, ask, fire, remove, agentDir, plant, bytes, code, status, file }; } test('T20 fire: a revoke that fails stops the fire with the file unchanged; one that succeeds revokes, keeps the directory and names the services outside the store', async (t) => { const f = await setup(t); f.ask('credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: '2099-01-01' }); assert.ok(existsSync(join(f.agentDir('coder'), 'gitea'))); // The revoke can't unlink the token, so it fails before the write: the file still lists coder. const before = f.bytes(); chmodSync(f.agentDir('coder'), 0o500); await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'}); chmodSync(f.agentDir('coder'), 0o700); assert.equal(f.bytes(), before); assert.ok(JSON.parse(before).launch.instances.includes('coder')); const fired = await f.fire('coder'); assert.equal(fired.delisted, true); assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] }); assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder')); assert.deepEqual(f.status('coder'), { gitea: 'revoked', vikunja: 'revoked' }); // The directory stays, empty, so the status reads revoked and the human removes the remote token. assert.deepEqual(readdirSync(f.agentDir('coder')), []); }); // webby-0094 N1: a fire of an instance already delisted (here a hand edit) still revokes its tokens, // with no write, and the instance is no longer firing. test('T20 fire: an instance already delisted still has its tokens revoked and the file is left alone', async (t) => { const f = await setup(t); f.ask('credentials.issue', { agent: 'rev-2', service: 'gitea', token: TOKEN, date: '2099-01-01' }); assert.ok(existsSync(join(f.agentDir('rev-2'), 'gitea'))); const doc = JSON.parse(f.bytes()); doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2'); writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 }); const before = f.bytes(); const fired = await f.fire('rev-2'); assert.equal(fired.delisted, false); assert.equal(fired.transaction, null); assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] }); assert.equal(f.bytes(), before); assert.deepEqual(readdirSync(f.agentDir('rev-2')), []); assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' }); }); test('T20 fire: with no entry in the running bus, a token on disk refuses and an empty store goes on', async (t) => { const f = await setup(t, { drop: ['rev-2'] }); f.plant('rev-2', 'gitea'); const before = f.bytes(); await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'}); assert.equal(f.bytes(), before); rmSync(join(f.agentDir('rev-2'), 'gitea')); const fired = await f.fire('rev-2'); assert.equal(fired.delisted, true); assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] }); }); test('T20 delete: a revoke that fails keeps the file; one that succeeds clears the store, deletes, and names the tracker bot', async (t) => { const f = await setup(t); f.ask('credentials.issue', { agent: 'rev-2', service: 'vikunja', token: TOKEN, date: '2099-01-01' }); // remove refuses a name it doesn't own (credential-file), so the delete stops with the file unchanged. writeFileSync(join(f.agentDir('rev-2'), 'notes'), 'x'); const before = f.bytes(); assert.equal(f.code(() => f.remove('rev-2')), 'credential-revoke-failed'); assert.equal(f.bytes(), before); // remove revokes the services it knows before it reads the directory (row 199), so the token is // already gone: a failed delete leaves the agent listed with nothing it can use. assert.equal(existsSync(join(f.agentDir('rev-2'), 'vikunja')), false); assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' }); rmSync(join(f.agentDir('rev-2'), 'notes')); const out = f.remove('rev-2'); assert.equal(out.bot, 'bot-demo-rev-2'); assert.equal(existsSync(f.agentDir('rev-2')), false); assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' }); const after = JSON.parse(f.bytes()); assert.equal(Object.hasOwn(after.roles, 'rev-2'), false); assert.ok(after.retired.includes('rev-2')); // The launcher's close carries the bot, so the CLI and the Console name it. const done = f.bus.lifecycleDone({ business: 'demo', request: out.request, transaction: out.transaction, bot: out.bot }); assert.equal(done.done.bot, 'bot-demo-rev-2'); }); test('T20 delete: with no entry in the running bus, a token file is cleared and the delete goes on; a file reference refuses', async (t) => { const f = await setup(t, { drop: ['rev-3'] }); f.plant('rev-3', 'gitea'); const out = f.remove('rev-3'); assert.equal(out.bot, 'bot-demo-rev-3'); assert.equal(existsSync(f.agentDir('rev-3')), false); assert.equal(Object.hasOwn(JSON.parse(f.bytes()).roles, 'rev-3'), false); // coder's vikunja is a file reference: deleting it stays a hand edit, refused at the request. const before = f.bytes(); assert.equal(f.code(() => f.remove('coder')), 'organization-credentials-out-of-scope'); assert.equal(f.bytes(), before); assert.deepEqual(f.status('coder'), { gitea: 'not-issued', vikunja: 'valid' }); }); test('T21 delete: a write between the raise and the take makes the delete refuse revision-conflict before it revokes anything', async (t) => { const f = await setup(t); f.ask('credentials.issue', { agent: 'rev-2', service: 'vikunja', token: TOKEN, date: '2099-01-01' }); const { request } = f.ask('instances.set', { revision: f.ask('instances').revision, instance: 'rev-2', op: 'delete' }); assert.equal(f.bus.lifecycleTake({ business: 'demo', request }).go, true); // A write after the raise: the request's revision is now stale. const raised = f.bytes(); f.ask('credentials.issue', { agent: 'rev-3', service: 'gitea', token: 'fake_other_token_9876543210', date: '2099-02-01' }); assert.ok(f.bytes() !== raised, 'the issue wrote the business file'); const before = f.bytes(); const entry = structuredClone(JSON.parse(before).roles['rev-2'].credentials); assert.equal(f.code(() => f.bus.lifecycleDelete({ business: 'demo', request })), 'revision-conflict'); // Nothing revoked: the token stays issued, Credentials is unchanged and no store file is removed. assert.equal(f.bytes(), before); assert.deepEqual(JSON.parse(f.bytes()).roles['rev-2'].credentials, entry); assert.equal(readFileSync(join(f.agentDir('rev-2'), 'vikunja'), 'utf8'), TOKEN + '\n'); assert.deepEqual(readdirSync(f.agentDir('rev-2')), ['vikunja']); assert.deepEqual(f.status('rev-2'), { gitea: 'not-issued', vikunja: 'valid' }); }); // Row 281 (#1786, d6 section 1) T1: pins what section 1 read from the code, and what still holds after the // change. Step B's revoke refuses while a grant's file is in the agent's store, which is why step G runs // first. A fire with no grant record goes straight to step B, so a stray host. file planted after the // start (the start sweep would unlink it) still refuses there. test('row 281 T1 fire: a host. or granted gitea file in the store refuses step B credential-revoke-failed, file unchanged', async (t) => { const f = await setup(t); f.plant('coder', 'host.web1'); const before = f.bytes(); await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'}); assert.equal(f.bytes(), before); assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']); rmSync(join(f.agentDir('coder'), 'host.web1')); assert.equal((await f.fire('coder')).delisted, true); }); // T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The // start loads the reference with no token, so the bus comes up and the entry reads not-issued. test('row 281 T1 start: a host grant record whose key file is gone starts and reads not-issued', async (t) => { const entry = { address: '192.0.2.10', port: 22, user: 'deploy', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] }; const f = await setup(t, { seed: (store) => { hostRecord(store, { host: 'web1', entry }); hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['uptime']] }); }, }); assert.equal(existsSync(join(f.agentDir('coder'), 'host.web1')), false); assert.equal(f.status('coder')['host.web1'], 'not-issued'); });