From 7965efd0fb615dcf873ee3ff0b438e3d55526427 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 21 Oct 2026 22:09:55 -0500 Subject: [PATCH] bus: row 236 r3, a revoke during a settle whoami ends revoked in the 401 and token-unknown branches (Dewey N5, #1742) --- agents/megavolt/work/queue-236/BUILD.md | 7 ++++++ packages/bus/src/broker.mjs | 7 ++++-- packages/bus/tests/gitea-grant.test.mjs | 29 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/agents/megavolt/work/queue-236/BUILD.md b/agents/megavolt/work/queue-236/BUILD.md index 3c9f21b50..dc729b59d 100644 --- a/agents/megavolt/work/queue-236/BUILD.md +++ b/agents/megavolt/work/queue-236/BUILD.md @@ -74,6 +74,10 @@ Each is a reading of the design where it left room. Dewey may rule otherwise. another id with `gitea-user-foreign`, any other status leaves the entry. Skips go in `grant.swept` and the sweep answer as `skipped: [{agent, user, id, reason}]`; a skipped entry is not left, so the next sweep does not retry it. +20. r3, Dewey N5 (dewey-0783): the settle's 401 branch and its grant-token-unknown branch take the same + revoke check. A revoke during the whoami ends the grant `revoked`, not `grant-lost`, and the + token-unknown branch neither holds the agent again nor writes `grant-token-unknown`, so no start + names a file the revoke unlinked. ## Named for DEFERRED (r2, Dewey N1-N4) @@ -126,3 +130,6 @@ r2 tests, each red on 8bf442be9 by reading: interrupted settled:back` after the revoke deleted it. - B2: "X-G14: a sweep retries a fire only for the user it fired: a remade or gone user is skipped and named". On 8bf442be9 the retry fires the remade user (token list, delete, PATCH) and has no `skipped`. +- N5 (r3): "X-G11: a revoke during a settle whoami that finds a foreign token or a 401 ends it revoked, not + held or lost". On 77dd43c9e the branches end `grant-token-unknown` (the agent back in tokenUnknown) and + `grant-lost`. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index f1362f1f5..3b0b61dce 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1808,13 +1808,16 @@ export class Broker { try { this.#giteaKeys.unregister(business, agent); } catch {} - this.#store.transaction(() => { + return this.#store.transaction(() => { + if (revoked()) return 'revoked'; this.#store.run('DELETE FROM gitea_grants WHERE business=? AND agent=?', business, agent); this.#giteaFailed(business, decision, agent, 'grant-lost', { user, ...(serviceLeft.length ? { serviceLeft } : {}) }); + return 'grant-lost'; }); - return 'grant-lost'; } if (who.status === 200 && who.id !== null) { + // A revoke during the whoami unlinked the file: holding it again would name a file that is gone. + if (this.#store.transaction(revoked)) return 'revoked'; // A live token this grant didn't mint: memory drops it, and the file, the record and both tokens stay. try { this.#giteaKeys.hold(business, agent); diff --git a/packages/bus/tests/gitea-grant.test.mjs b/packages/bus/tests/gitea-grant.test.mjs index 9c21f009e..ef450167e 100644 --- a/packages/bus/tests/gitea-grant.test.mjs +++ b/packages/bus/tests/gitea-grant.test.mjs @@ -991,6 +991,35 @@ test('X-G11: a revoke while a sweep settles a rotation, forward or back, ends it } }); +test('X-G11: a revoke during a settle whoami that finds a foreign token or a 401 ends it revoked, not held or lost', async (t) => { + for (const kind of ['grant-token-unknown', 'grant-lost']) { + const f = await setup(t); + const { id } = await openRotation(f); + // A token this grant did not mint, or one Gitea does not know. + const file = kind === 'grant-token-unknown' ? f.gitea.addToken(USER, 'foreign', SEAT_SCOPES).sha1 : 'f'.repeat(40); + writeFileSync(f.tokenFile, file + '\n'); + let release; + const held = new Promise((r) => (release = r)); + const seat = (e) => e.method === 'GET' && e.path === '/api/v1/token' && e.as !== 'mosaic-grant'; + f.gitea.rule(seat, async () => { + await held; + return { pass: true }; + }); + const sweep = f.call('human', 'grants.sweep', { service: 'gitea' }); + await until(() => f.gitea.requests.some(seat)); + const revoke = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' }); + release(); + await revoke; + const swept = await sweep; + await f.broker.giteaIdle(); + assert.deepEqual(swept.settled, [{ decision: id, outcome: 'revoked' }], kind); + assert.equal(f.ended(id).code, 'revoked'); + assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.code === kind), false); + assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []); + assert.equal(f.record(), undefined); + } +}); + test('X-G12: a token that reaches wider than its forks fails with the repository named', async (t) => { const collab = await setup(t); collab.gitea.addRepo('other', 'secret', { collaborators: [USER] });