v1.68.2.0 fix: tunnel revoke exists and revokes everything — setup keys included, verified live (#2646)

* 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>

* 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>

* fix(browse): CLI always sends explicit pair scopes via shared DEFAULT_PAIR_SCOPES

The effective pairing default lived in two places: the CLI omitted scopes
unless --restrict was passed, and the server filled in its own literal.
handlePairAgent now always sends an explicit scopes list and both sides
reference one exported constant, DEFAULT_PAIR_SCOPES, so the default cannot
silently drift again (pinned by a server-auth source tripwire).

Three input traps closed in the same surface:
- Bare --restrict (or --restrict swallowing the next flag) parsed as "no
  restriction" and silently granted FULL access, the opposite of the user's
  intent. validatePairAgentFlags rejects it pre-server, before any consent
  gate, so an arg error never boots a daemon.
- A scopes list could smuggle the control scope past the explicit flag:
  --restrict "read,control" minted a control-scoped session with no
  --control. /pair now 400s on control in a scopes list without the control
  flag, and the CLI points the user at --control.
- Option typos validated only at exchange time: createSetupKey stored any
  scope string and any rateLimit, so /pair returned 200 with a poisoned
  setup key whose failure surfaced to the REMOTE agent at /connect as a
  misleading "Invalid request body". Shared validation now runs in both
  creators and throws typed InvalidScopeError; /pair and /token 400 with the
  message, naming the bad scope or negative rateLimit. Also
  `opts.rateLimit || 10` became `?? 10` so the documented "0 = unlimited"
  survives the /pair path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): 403 hint stops recommending --admin; invariant names both scope defaults

The scope-denied hint told restricted agents to "re-pair with --admin for
eval/cookies/storage" — but --admin is a legacy alias for --control, so
following it over-granted browser-wide destructive commands on top of the
admin scope the default already carries. The hint now matches the CLI's
sibling wording: re-pair without --restrict for page access, --control for
browser control.

Registry invariant #2 claimed "admin scope denied by default" three releases
after b73f3644 deliberately made /pair grant admin. It now names BOTH
defaults precisely (registry API functions default read+write; the /pair
ceremony grants DEFAULT_PAIR_SCOPES) so the header cannot lie one layer down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pair-agent): document the full-access default, --restrict, and real revocation

The pairing docs still described the pre-b73f3644 model: read+write default,
--admin as the opt-in for JS/cookies/storage. Reality for three releases:
/pair grants read+write+admin+meta (the pairing ceremony is the trust
boundary) and --admin is a legacy alias for --control. A user following the
skill believed they granted a sandboxed session and actually granted JS
execution on their logged-in browser.

pair-agent/SKILL.md.tmpl (SKILL.md regenerated in this commit) now states
the real default, the tunnel-allowlist nuance (eval works remotely; the
js/cookies/storage commands are local-only), --restrict for sandboxed
sessions with an untrusted-content advisory (scope caps prompt-injection
blast radius), and --control for browser-wide ops. "Revoking access"
documents the now-real tunnel revoke (deletes session + pending setup keys,
verifies against the agent list) and tunnel agents, and replaces the
never-implemented `tunnel rotate` with `$B stop` — tokens are memory-only,
so a daemon restart already rotates everything.

REMOTE_BROWSER_ACCESS.md: /connect example shows the real default scopes,
the scope table gains the control row, the 403 hint row matches the new
server wording, and the false claim that /sidebar-chat is on the tunnel
allowlist is gone (TUNNEL_PATHS is /connect + /command; /sidebar-chat no
longer exists in server.ts at all). ARCHITECTURE.md drops the same phantom
endpoint from the allowlist prose and endpoint table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.68.2.0: revoke-all, real tunnel revoke, truthful pairing docs

Version slot allocated against the live remote via bin/gstack-next-version
(clean patch bump from 1.68.1.0, no collision). CHANGELOG entry covers the
revoke-all fix, the new tunnel revoke/agents CLI, the explicit-scopes wire
contract, and the pairing-docs truth pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): adversarial-review hardening — 6 findings fixed, regression-pinned

Pre-push adversarial review (4 lenses, refute-style verification: 13 raw
findings, 7 refuted, 6 confirmed) caught these; each fix carries a pin:

1. --restrict=read (equals form) sailed past validatePairAgentFlags —
   hasFlag/parseFlag are exact-token matches — so the user asked for a
   read-only sandbox and silently got FULL access: the exact failure mode
   this branch claims to close. The equals form is now a hard error before
   any server work.
2. handleTunnel trimmed the agent name but clientIds are stored verbatim,
   so a space-padded agent was unrevocable by the documented kill switch
   (trimmed DELETE 404'd while the grant stayed live). Names now pass
   through verbatim; the live-daemon test revokes ' padded'.
3. The sole pin for "CLI always sends explicit scopes" passed vacuously on
   a simulated revert: toContain('DEFAULT_PAIR_SCOPES') was satisfied by a
   comment. The tripwire now matches the code shape with a regex and bans
   the conditional spread formatting-insensitively.
4. The rewritten 403 scope hint was unpinned — new e2e asserts it names
   --restrict and --control and never --admin.
5. tunnelRevoke's verify-failure and HTTP-error branches and tunnelAgents'
   unreadable-list branch had no coverage — three stub-daemon pins added
   (an unreadable list must never render as "No paired agents").
6. CHANGELOG claimed "40+ new test cases"; the honest count is 35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-19 22:33:46 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 9da6692930
commit 51932eceef
15 changed files with 1186 additions and 70 deletions
+45 -11
View File
@@ -6,7 +6,8 @@ description: |
Pair a remote AI agent with your browser. One command generates a setup key and
prints instructions the other agent can follow to connect. Works with OpenClaw,
Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent
gets its own tab with scoped access (read+write by default, admin on request).
gets its own tab with full page access by default (the pairing ceremony is the
trust boundary; --restrict narrows it).
Use when asked to "pair agent", "connect agent", "share browser", "remote browser",
"let another agent use my browser", or "give browser access". (gstack)
voice-triggers:
@@ -185,10 +186,18 @@ ngrok, start the tunnel, and print the instruction block with the tunnel URL:
$B pair-agent --client TARGET_HOST
```
If the user also needs admin access (JS execution, cookies, storage):
Default access already includes JS execution. To also grant browser-wide
control (stop, restart, disconnect):
```bash
$B pair-agent --admin --client TARGET_HOST
$B pair-agent --control --client TARGET_HOST
```
For a less-trusted agent, narrow the scopes instead:
```bash
$B pair-agent --restrict read --client TARGET_HOST # read-only
$B pair-agent --restrict "read,write" --client TARGET_HOST # no JS, no cookies
```
**CRITICAL: You MUST output the full instruction block to the user.** The command
@@ -260,15 +269,28 @@ side panel if you have GStack Browser open."
## What the remote agent can do
With default (read+write) access:
Default access is read+write+admin+meta. The trust boundary is the pairing
ceremony, not the scope:
- Navigate to URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Cannot execute arbitrary JavaScript, read cookies, or access storage
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
With admin access (--admin flag):
- Everything above, plus JS execution, cookie access, storage access
- Use sparingly. Only for agents you fully trust.
Remote agents go through the tunnel command allowlist: `eval` works, but the
`js`, `cookies`, and `storage` commands are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all four.
With --restrict (`--restrict read`, `--restrict "read,write"`):
- Sandboxed sessions: read-only, or read+write with no JS, cookie, or storage
access. Pair this way when the remote agent will read untrusted web content:
a trusted agent can be prompt-injected by pages it reads, and scope caps the
blast radius (eval works over the tunnel).
- `--restrict` never grants `control`; that scope stays behind --control.
With --control (--admin is the legacy alias):
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
- Only for agents you fully trust.
## Troubleshooting
@@ -316,9 +338,21 @@ To disconnect a specific agent:
$B tunnel revoke AGENT_NAME
```
To disconnect all agents and rotate the root token:
The command deletes every token for that agent (the session and any pending
setup keys) and re-reads the agent list to prove it's gone.
See who's paired:
```bash
# This invalidates ALL scoped tokens immediately
$B tunnel rotate
$B tunnel agents
```
Unexchanged setup keys show as "(pending)"; `tunnel revoke` removes them too.
To disconnect ALL agents at once, stop the daemon. Scoped tokens live in
daemon memory and never survive a restart; the next command boots a fresh
daemon with a new root token:
```bash
$B stop
```