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
+14 -2
View File
@@ -2330,7 +2330,15 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
const clientId = url.pathname.slice('/token/'.length);
// decodeURIComponent so CLI-encoded names (spaces, UTF-8) round-trip.
let clientId: string;
try {
clientId = decodeURIComponent(url.pathname.slice('/token/'.length));
} catch {
return new Response(JSON.stringify({ error: 'Malformed client ID encoding' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
const revoked = revokeToken(clientId);
if (!revoked) {
return new Response(JSON.stringify({ error: `Agent "${clientId}" not found` }), {
@@ -2350,13 +2358,17 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
const agents = listTokens().map(t => ({
// includeSetup: pending (unexchanged) setup keys are live grants the
// operator must be able to see — without them, revoking a paired-but-
// never-connected agent "works" while the list shows nothing.
const agents = listTokens({ includeSetup: true }).map(t => ({
clientId: t.clientId,
scopes: t.scopes,
domains: t.domains,
expiresAt: t.expiresAt,
commandCount: t.commandCount,
createdAt: t.createdAt,
pending: t.type === 'setup',
}));
return new Response(JSON.stringify({ agents }), {
status: 200, headers: { 'Content-Type': 'application/json' },