fix(browse): revokeToken deletes ALL tokens for a clientId, not the first Map hit

revokeToken deleted the first Map entry matching the clientId and returned
true. After a normal pairing, two entries share one clientId: the spent setup
key (kept by exchangeSetupKey for idempotent re-exchange) and the session
token, in that insertion order. Revoke ate the setup key, reported success,
and the live session survived: DELETE /token/<id> returned a false 200 while
/agents kept listing the agent. Worse, an unspent setup key created after the
session survived revoke, so a "revoked" agent could POST /connect and mint a
fresh session within the key's 5-minute validity window.

revokeToken now deletes every matching entry and returns the delete count
(truthy-compatible with the old boolean). The DELETE /token handler logs
"Revoked N token(s)" and returns tokens_deleted so the multi-token class
stays visible; revokeSkillToken wraps Boolean() to keep its documented
contract. Regression tests pin shapes a (spent-key shadowing), b (re-grant
hole), c (multiple pending keys), and bystander isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-20 03:48:23 +00:00
co-authored by Claude Fable 5
parent 9da6692930
commit b9eb108f16
4 changed files with 58 additions and 12 deletions
+2 -2
View File
@@ -2337,8 +2337,8 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 404, headers: { 'Content-Type': 'application/json' },
});
}
console.log(`[browse] Revoked token for: ${clientId}`);
return new Response(JSON.stringify({ revoked: clientId }), {
console.log(`[browse] Revoked ${revoked} token(s) for: ${clientId}`);
return new Response(JSON.stringify({ revoked: clientId, tokens_deleted: revoked }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
+1 -1
View File
@@ -87,5 +87,5 @@ export function mintSkillToken(opts: MintSkillTokenOptions): TokenInfo {
* token returns false but is not an error.
*/
export function revokeSkillToken(skillName: string, spawnId: string): boolean {
return revokeToken(skillClientId(skillName, spawnId));
return Boolean(revokeToken(skillClientId(skillName, spawnId)));
}
+12 -6
View File
@@ -417,17 +417,23 @@ export function recordCommand(token: string): void {
}
/**
* Revoke a token by client ID. Returns true if found and revoked.
* Revoke ALL tokens for a client ID — the session token and every setup key,
* spent or unspent. Deleting only the first match left two holes: a spent
* setup key (kept for idempotent re-exchange) shadowed the session token, so
* revoke reported success while the live session survived; and an unspent
* setup key surviving revoke let a "revoked" agent POST /connect into a
* fresh session. Returns the number of tokens deleted (0 = nothing found).
*/
export function revokeToken(clientId: string): boolean {
export function revokeToken(clientId: string): number {
let deleted = 0;
for (const [token, info] of tokens) {
if (info.clientId === clientId) {
tokens.delete(token);
rateBuckets.delete(clientId);
return true;
tokens.delete(token); // Map tolerates delete during for...of iteration
deleted++;
}
}
return false;
if (deleted > 0) rateBuckets.delete(clientId);
return deleted;
}
/**