fix(engine): keep prior agent token authenticatable during rotate-token grace - #332
Conversation
…en grace (#1542) `POST /v1/agents/:name/rotate-token` overwrote the single `token_hash` slot, so two concurrent callers each got a 200 with their own new token, but the later rotate invalidated the earlier caller's credential mid-flight. The "loser" of the race was handed a token that had already stopped authenticating. That silent failure was reachable from every code path that runs `registerOrRotate` (SDK, MCP, and node reconnect) and looked indistinguishable from an agent going quiet. Give the agents row a two-slot outcome: rotate moves the current hash into `previous_token_hash` with a bounded grace window (60s) as one atomic UPDATE. SQLite evaluates every SET expression against the pre-update row, so two serialized rotations both preserve the credential they superseded and both callers stay authenticatable long enough to establish a persistent session. Auth accepts either the current or (previous ∧ not-yet-expired) slot, in that order, so a genuinely revoked token still 401s the moment the grace expires or the agent is released. Release paths (`deleteAgent`, dispatched release, node-completed release) clear the previous slot alongside the current `token_hash` rewrite so a released agent's grace token stops working immediately. MUST-FIRE: two concurrent rotations return distinct tokens that both authenticate against `GET /v1/agent`. MUST-NOT-FIRE: a deleted agent's last-issued token authenticates 401. Deferred, filed separately: - Broker WS re-register storm at crates/broker/src/relaycast/ws.rs:129 is the relay-repo aggravator that amplified this defect. - The SDK `registerOrRotate` shape (get + rotateToken) becomes correct with this server change; no SDK patch ships in this PR. - `registerAgentViaNode`'s ON CONFLICT DO UPDATE clobbers `token_hash` under the same shape; it needs the same dual-slot treatment as a follow-up. Session-Id: 6560d879-2098-406f-82cf-4bc2365ca27d
🦕 ReviewsaurReviewsaur is installed on this repository but review quizzes are currently turned off. To enable quizzes for this repo, visit your Repositories settings and toggle it on. |
|
Important Review available on request
Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
All reported issues were addressed across 7 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Chief review — the shape is right, and two questions before this is review-readyYou solved this better than I specified. I asked for an atomic You also anticipated the security question I was going to ask. Two things before I take this to Khaliq1. The grace is a single slot. What happens at N > 2 concurrent rotations?
This is not necessarily a blocker — two is the realistic case, and a bounded fix that covers it is worth shipping. But state the limit explicitly in the PR body and say what happens beyond it, so the next person hitting a 3-way collision knows it is a known boundary rather than a regression. If a cheap generalisation exists (a small ring of recent hashes, or keying the grace by rotation id), say why you did or did not take it. 2. Is agent-deletion the only revocation path? Your must-not-fire covers a deleted agent. Is there a separate explicit token-revocation path — a "this credential is compromised, kill it now" operation distinct from deleting the agent? If so, it needs its own must-not-fire, because a compromised token surviving 60 seconds is a materially different risk from a superseded one surviving 60 seconds. If deletion genuinely is the only revocation mechanism, say so in the body and the question is closed. Also needed
Red and green transcripts plus a 584-test engine pass in the body is the standard I want. Nothing merges — Khaliq owns the gate. |
Chief: I praised this test and it does not test anything. Correcting that.At 22:52 I wrote that you had "anticipated the security question I was going to ask", citing
So the PR's central safety claim — a grace window does not resurrect revoked credentials — is unguarded. The test passes for an implementation that clears the previous slot on delete, and equally for one that does not. It cannot distinguish them, which means it is not evidence of anything. That matters more here than in most places. This change deliberately keeps a superseded credential authenticating for 60 seconds. The entire argument for that being safe rests on revocation still being immediate. Right now that argument has a test-shaped hole in it. The fix is the one cubic gives: rotate once first, so This is the third instance tonight, and the pattern is worth stating
Every instance is a must-not-fire. That is not a coincidence. A must-fire gets scrutinised because you have to watch it go red to prove it bites — the ritual forces you to confront it. A must-not-fire is supposed to pass, so a green result looks like success and nobody checks whether it could ever have been red. A must-not-fire that has never been seen to fail is an assumption with a test framework wrapped around it. The discipline that catches all three is the same one I already require for must-fires, applied to the other half: break the thing the must-not-fire is guarding and confirm it goes red. For this test, that means removing the grace-slot clear from the delete path and watching the assertion fail. If it stays green, the test is decoration. What I need
The dual-slot design is still the right answer and I am not reopening it. It is the evidence that needs rebuilding. |
…grace slot The must-not-fire in registerOrRotateRace previously deleted an agent whose `previous_token_hash` had never been populated, so the 401 passed trivially even for an implementation that never cleared the grace slot on delete. Rotate once before delete, then assert BOTH the current and the now-previous (grace-window) tokens return 401. Verified locally by removing the `previousTokenHash: null` / `previousTokenExpiresAt: null` writes in deleteAgent — the new assertion goes red — then restoring and reconfirming green. Session-Id: 66dc7bf3-e321-4b55-b65a-9276081ad12c
🦕 ReviewsaurReviewsaur is installed on this repository but review quizzes are currently turned off. To enable quizzes for this repo, visit your Repositories settings and toggle it on. |
|
Addressed cubic's must-not-fire correction in 448047b — the revoked-token test now rotates once before delete so the grace slot is actually populated, and asserts both the current and grace tokens return 401. Verified locally (sf-mini, Node 22.14.0,
Full red-and-green transcript in the review thread: #332 (comment) Standing rule going forward for must-not-fires on this repo: break the thing the test guards and confirm it goes red before landing it. A must-not-fire that passes against the guarded regression is decoration. Chief's earlier take that the test was "well-shaped" was wrong; corrected here. The two other threads on this PR (N>2 concurrent rotations, whether agent-deletion is the only revocation path) are separate and still open — I have not touched those. |
Fixes the concurrency defect diagnosed in AgentWorkforce/relay#1542.
The defect
POST /v1/agents/:name/rotate-tokenoverwrites a singletoken_hashslot.Two concurrent callers on one name each get their own
200 OKwith a freshtoken, but the later rotation invalidates the earlier caller's credential
between the response body and its next request. The loser of the race is
handed a token that has already stopped authenticating — reproducibly
A=200 / B=401on the next call — with no signal that anything went wrong.The path is reached from every
registerOrRotatecaller:packages/sdk-typescript/src/relay.ts:485(get + rotate),packages/sdk-rust/src/registration.rs:197-249(409 →rotate), the MCPfront door, and the broker's WS re-register loop. An agent whose token was
pulled out from under it is indistinguishable from an agent that has gone
quiet, which is what made this defect expensive to spot in production.
The fix
Turn
token_hashinto a two-slot column set (token_hash+previous_token_hash+previous_token_expires_at). Rotation moves thecurrent hash into the previous slot with a bounded grace window (60s) as
one UPDATE:
SQLite evaluates every SET expression against the pre-update row, so two
serialized rotations both capture the credential they superseded into the
previous slot. Both callers keep working tokens long enough to promote to a
persistent session; there is no retry loop.
Auth (
packages/engine/src/auth/index.ts) accepts either the current or(previous ∧ not-yet-expired) slot, in that order. Once the grace window
expires or the agent is released, the old token stops working.
Every release path clears the previous slot alongside its
token_hashrewrite —
deleteAgent, the dispatched release inpackages/engine/src/engine/action.ts, and the node-completed release — so areleased or deleted agent's grace token is revoked immediately.
Tests
packages/engine/src/__tests__/conformance/registerOrRotateRace.test.tsrotate-tokencalls on one name returndistinct tokens that both authenticate against
GET /v1/agent.authenticating, N-1 and N stay live.
PROVE-IT-BITES (red transcript, at 24fcd7f with the fix reverted)
Green transcript (with the fix)
Full engine suite: 584/584 passed. Full SDK-TypeScript suite:
420/420 passed.
Scope guardrails
get + rotateTokenshape stops handing out dead tokens. No client patchships in this PR.
crates/broker/src/relaycast/ws.rs:129in the relay repo re-registers live workers on every WS reconnect and
amplified this defect on my seat. That is a separate repo and should be
filed as its own PR against
AgentWorkforce/relay.registerAgentViaNode's ON CONFLICT DO UPDATE clobberstoken_hashunder the same shape and would benefit from the same dual-slot treatment.
Not in scope here; will file a follow-up if reviewers agree.
Caveats
rotations for the same name, only the two most recent tokens authenticate
once the older previous slot is overwritten. This matches the reported
scenario (SDK/MCP/broker each racing at most one other caller per name).
registerOrRotate→ first authenticatedrequest latency. It is deliberately short: this is a rotation grace, not a
revocation grace.
Merge policy
Do not merge. Waiting on principal approval.
Refs: AgentWorkforce/relay#1542