diff --git a/agents/megavolt/work/queue-236/BUILD.md b/agents/megavolt/work/queue-236/BUILD.md index dc729b59d..9c361089e 100644 --- a/agents/megavolt/work/queue-236/BUILD.md +++ b/agents/megavolt/work/queue-236/BUILD.md @@ -81,7 +81,9 @@ Each is a reading of the design where it left room. Dewey may rule otherwise. ## Named for DEFERRED (r2, Dewey N1-N4) -None is a small local change, so none is in r2. +None is a small local change, so none is in r2. sage-2388: all four DEFERRED; N3 and N4 get a bus README +sentence each in r3 (a left admin token is deleted by hand at Gitea; a running executor keeps the admin +api it started with). - N1: an admin rotate cut between the mint and the slot write leaves a token at Gitea that no event names. A `grant.admin rotate-begin {oldName}` before the mint would close it; that is a new event shape. @@ -130,6 +132,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`. +- N5 (r3, sage-2388): "X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not + held" and "X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost". On 77dd43c9e + they end `grant-token-unknown` (the agent back in tokenUnknown) and `grant-lost`. diff --git a/packages/bus/README.md b/packages/bus/README.md index 21768d179..12de35f57 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -229,7 +229,7 @@ an operator inspect content and delivery evidence after an uncertain result. | `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. | | `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). | | `grants.sweep` | `{service:"gitea"}`; the human or the tester. Closes open revokes, settles open rotations again, and retries each `serviceLeft` entry. A fire retry (`tokens`, `login`) first checks the user's id as the revoke close does, and a gone or remade user is skipped with `gitea-user-gone` or `gitea-user-foreign`. Writes `grant.sweep` and `grant.swept {left,skipped,settled,closed}`. | -| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `/secrets/grants//gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. | +| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `/secrets/grants//gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. A left admin token that set or remove drops from `adminTokenLeft`, or one shown as `unknown`, has to be deleted by hand at Gitea. A running executor, revoke close or sweep keeps the admin api it started with: rotate voids only decisions not yet run, and remove takes effect from the next run. | Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a `detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes diff --git a/packages/bus/tests/gitea-grant.test.mjs b/packages/bus/tests/gitea-grant.test.mjs index ef450167e..067be95a3 100644 --- a/packages/bus/tests/gitea-grant.test.mjs +++ b/packages/bus/tests/gitea-grant.test.mjs @@ -991,33 +991,41 @@ 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); - } +// A sweep settles the open rotation while the seat whoami is held with `file` in the store file; a revoke +// lands during the hold. Checks the rotation ended revoked with no record; returns the fixture and its decision. +async function revokeDuringWhoami(t, file) { + const f = await setup(t); + const { id } = await openRotation(f); + writeFileSync(f.tokenFile, file(f) + '\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' }]); + assert.equal(f.ended(id).code, 'revoked'); + assert.equal(f.record(), undefined); + return { f, id }; +} + +test('X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not held', async (t) => { + const { f, id } = await revokeDuringWhoami(t, (g) => g.gitea.addToken(USER, 'foreign', SEAT_SCOPES).sha1); + assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.code === 'grant-token-unknown'), false); + assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []); +}); + +test('X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost', async (t) => { + const { f, id } = await revokeDuringWhoami(t, () => 'f'.repeat(40)); + assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.code === 'grant-lost'), false); }); test('X-G12: a token that reaches wider than its forks fails with the repository named', async (t) => {