feat(browse): tunnel revoke/agents CLI with post-revoke verification

`$B tunnel revoke <name>` was documented in the instruction block,
pair-agent/SKILL.md, and REMOTE_BROWSER_ACCESS.md but implemented nowhere:
the CLI forwarded it to the daemon as Unknown command 'tunnel', and nothing
in the repo called DELETE /token/:clientId or GET /agents.

New pre-server short-circuit (#2254 pattern: tokens are memory-only, never
boot a daemon to revoke against it). `tunnel revoke <name>` DELETEs the
token, prints the deleted count ("(count unknown)" for old daemons that
answer {revoked} without tokens_deleted), then RE-READS GET /agents to prove
the agent is gone. The still-listed branch is the version-skew net: a new
CLI against a still-running old daemon with the first-match revoke bug exits
1 and says to re-run (each old-daemon call deletes the next match) or stop.
An alive pid with an unreachable port reports "Could not reach daemon"
(exit 1), never a false "no daemon". `tunnel agents` lists sessions plus
pending (unexchanged) setup keys, which GET /agents now exposes via
listTokens({includeSetup}) — without them the revocation view was blind to
a paired-but-never-connected agent. Setup-key tokens never leave the server.
DELETE /token/ now decodeURIComponents the clientId (400 on malformed
encoding) so CLI-encoded names round-trip.

Tests: subprocess CLI coverage (usage paths, no-daemon exit 0 without
spawning, live pair/connect/revoke loop, pending-key listing), stub-daemon
pins for the skew and unreachable branches, and e2e pins for revoke-all
semantics, percent-encoded ids, and the second-DELETE-is-404 regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-20 03:53:56 +00:00
co-authored by Claude Fable 5
parent b9eb108f16
commit f28bfd0158
6 changed files with 544 additions and 3 deletions
+12
View File
@@ -366,6 +366,18 @@ describe('token-registry', () => {
createSetupKey({}); // setup keys not listed
expect(listTokens()).toHaveLength(2);
});
it('includeSetup lists pending setup keys but hides spent ones', () => {
createToken({ clientId: 'sess' });
createSetupKey({ clientId: 'pending' });
const spent = createSetupKey({ clientId: 'spent' });
exchangeSetupKey(spent.token);
expect(listTokens().map(t => t.clientId).sort()).toEqual(['sess', 'spent']);
const withSetup = listTokens({ includeSetup: true });
// Pending key = a live grant the operator must see; the SPENT key is
// re-exchange bookkeeping for the already-listed session and stays hidden.
expect(withSetup.filter(t => t.type === 'setup').map(t => t.clientId)).toEqual(['pending']);
});
});
describe('serialization', () => {