* 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>
11 KiB
Remote Browser Access — How to Pair With a GStack Browser
A GStack Browser server can be shared with any AI agent that can make HTTP requests. The agent gets scoped access to a real Chromium browser: navigate pages, read content, click elements, fill forms, take screenshots. Each agent gets its own tab.
This document is the reference for remote agents. The quick-start instructions are
generated by $B pair-agent with the actual credentials baked in.
Architecture
Your Machine Remote Agent
───────────── ────────────
GStack Browser Server Any AI agent
├── Chromium (Playwright) (OpenClaw, Hermes, Codex, etc.)
├── Local listener 127.0.0.1:LOCAL │
│ (bootstrap, CLI, sidebar, cookies) │
├── Tunnel listener 127.0.0.1:TUNNEL ◄───────┤
│ (pair-agent only: /connect and │
│ /command — locked allowlist) │
├── ngrok tunnel (forwards tunnel port only) │
│ https://xxx.ngrok.dev ─────────────────┘
└── Token Registry
├── Root token (local listener only)
├── Setup keys (5 min, one-time)
├── Session tokens (24h, scoped)
└── SSE session cookies (30 min, stream-scope)
Dual-listener architecture (v1.6.0.0)
The daemon binds two HTTP sockets. The local listener serves the full command surface to 127.0.0.1 only and is never forwarded. The tunnel listener is bound lazily on /tunnel/start (and torn down on /tunnel/stop) with a locked path allowlist. ngrok forwards only the tunnel port.
A caller who stumbles onto your ngrok URL cannot reach /health, /cookie-picker, /inspector/*, or /welcome — those paths don't exist on that TCP socket. Root tokens sent over the tunnel get 403. The tunnel listener accepts only /connect and /command (with a scoped token + the 26-command browser-driving allowlist).
See ARCHITECTURE.md for the full endpoint table.
Connection Flow
- User runs
$B pair-agent(or/pair-agentin Claude Code) - Server creates a one-time setup key (expires in 5 minutes)
- User copies the instruction block into the other agent's chat
- Remote agent runs
POST /connectwith the setup key - Server returns a scoped session token (24h default)
- Remote agent creates its own tab via
POST /commandwithnewtab - Remote agent browses using
POST /commandwith its session token + tabId
API Reference
Authentication
All command endpoints require a Bearer token:
Authorization: Bearer gsk_sess_...
/connect is unauthenticated (rate-limited) — it's how a remote agent exchanges a setup key for a scoped session token. /health is unauthenticated on the local listener (liveness/status only — never a token) but does NOT exist on the tunnel listener (404). Extension token bootstrap is POST /extension-token on the local listener, gated by the pinned chrome-extension:// Origin; it is not on the tunnel surface either.
SSE endpoints (/activity/stream, /inspector/events) accept either a Bearer token or the HttpOnly gstack_sse cookie (minted via POST /sse-session, 30-minute TTL, stream-scope only — cannot be used against /command). As of v1.6.0.0 the ?token=<ROOT> query-string auth is no longer accepted.
Endpoints
POST /connect
Exchange a setup key for a session token. No auth required. Rate-limited to 300/minute (flood defense — setup keys are 24 random bytes, unbruteforceable).
Request: {"setup_key": "gsk_setup_..."}
Response: {"token": "gsk_sess_...", "expires": "ISO8601", "scopes": ["read","write","admin","meta"], "agent": "agent-name"}
POST /command
Send a browser command. Requires Bearer auth.
Request: {"command": "goto", "args": ["https://example.com"], "tabId": 1}
Response: (plain text result of the command)
GET /health
Server status. No auth required. Returns status, tabs, mode, uptime.
Never carries a token — extension token bootstrap is POST /extension-token
(local listener only, validates the pinned chrome-extension:// Origin and a
loopback Host; 403 otherwise). Not reachable over the tunnel (404).
Commands
Navigation
| Command | Args | Description |
|---|---|---|
goto |
["URL"] |
Navigate to a URL |
back |
[] |
Go back |
forward |
[] |
Go forward |
reload |
[] |
Reload page |
Reading Content
| Command | Args | Description |
|---|---|---|
snapshot |
["-i"] |
Interactive snapshot with @ref labels (most useful) |
text |
[] |
Full page text |
html |
["selector?"] |
HTML of element or full page |
links |
[] |
All links on page |
screenshot |
["/tmp/s.png"] |
Take a screenshot |
url |
[] |
Current URL |
Interaction
| Command | Args | Description |
|---|---|---|
click |
["@e3"] |
Click an element (use @ref from snapshot) |
fill |
["@e5", "text"] |
Fill a form field |
select |
["@e7", "option"] |
Select dropdown value |
type |
["text"] |
Type text (keyboard) |
press |
["Enter"] |
Press a key |
scroll |
["down"] |
Scroll the page |
Tabs
| Command | Args | Description |
|---|---|---|
newtab |
["URL?"] |
Create a new tab (required before writing) |
tabs |
[] |
List all tabs |
closetab |
["id?"] |
Close a tab |
The Snapshot → @ref Pattern
This is the most powerful browsing pattern. Instead of writing CSS selectors:
- Run
snapshot -ito get an interactive snapshot with labeled elements - The snapshot returns text like:
[Page Title] @e1 [link] "Home" @e2 [button] "Sign In" @e3 [input] "Search..." - Use the
@erefs directly in commands:click @e2,fill @e3 "search query"
This is how the snapshot system works, and it's much more reliable than guessing
CSS selectors. Always snapshot -i first, then use the refs.
Scopes
| Scope | What it allows |
|---|---|
read |
snapshot, text, html, links, screenshot, url, tabs, console, etc. |
write |
goto, click, fill, scroll, newtab, closetab, etc. |
admin |
eval, js, cookies, storage, cookie-import, useragent, etc. |
meta |
tab, diff, frame, responsive, watch |
control |
stop, restart, disconnect, state, handoff — browser-wide destructive ops |
Paired agents get read+write+admin+meta by default; the pairing ceremony is the trust boundary. --restrict narrows the list (it can never grant control). --control adds the control scope (--admin is a legacy alias). Over the tunnel, the js/cookies/storage commands are blocked by the command allowlist regardless of scope; eval works. Pair with --restrict "read,write" when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
Tab Isolation
Each agent owns the tabs it creates. Rules:
- Read: Any agent can read any tab (snapshot, text, screenshot)
- Write: Only the tab owner can write (click, fill, goto, etc.)
- Unowned tabs: Pre-existing tabs are root-only for writes
- First step: Always
newtabbefore trying to interact
Error Codes
| Code | Meaning | What to do |
|---|---|---|
| 401 | Token invalid, expired, or revoked | Ask user to run /pair-agent again |
| 403 | Command not in scope, tab not yours, or not on the tunnel allowlist | Use newtab; the user can re-pair without --restrict or with --control |
| 429 | Rate limit exceeded (>10 req/s) | Wait for Retry-After header |
Security Model
- Physical port separation. Local listener and tunnel listener are separate TCP sockets. ngrok only forwards the tunnel port. Tunnel callers cannot reach bootstrap endpoints at all (404, wrong port).
- Tunnel command allowlist.
/commandover the tunnel only accepts 26 browser-driving commands (goto, click, fill, snapshot, text, newtab, tabs, back, forward, reload, closetab, etc.). Server-management commands (tunnel, pair, token, useragent, js) are denied on the tunnel. - Root token is tunnel-blocked. A request bearing the root token over the tunnel listener returns 403 with a pairing hint. Only scoped session tokens work over the tunnel.
- Setup keys expire in 5 minutes and can only be used once.
- Session tokens expire in 24 hours (configurable).
- The root token never appears in instruction blocks or connection strings.
- Control scope (stop/restart/disconnect) is denied by default and never rides in via a scopes list. Admin is granted at pairing;
js/cookies/storagestay blocked over the tunnel by the command allowlist. Use--restrictfor less-trusted agents. - Tokens can be revoked instantly:
$B tunnel revoke agent-namedeletes the session plus any pending setup keys and verifies against the live agent list.$B tunnel agentsshows who's paired (pending setup keys included).$B stopclears everything — tokens never survive the daemon. - SSE auth uses a 30-minute HttpOnly SameSite=Strict cookie, stream-scope only (never valid against
/command). - Path traversal guarded on
/welcome—GSTACK_SLUGmust match^[a-z0-9_-]+$or falls back to the built-in template. - SSRF guards on
goto,download, and scrape paths — validates URL target against a localhost/private-range blocklist. - Tunnel surface denial logging. Every rejection on the tunnel listener (
path_not_on_tunnel,root_token_on_tunnel,missing_scoped_token,disallowed_command:*) is appended to~/.gstack/security/attempts.jsonlwith timestamp, source IP, path, method. Rate-capped at 60 writes/min. - Egress receipt on tunnel start (v1.63+). Every tunnel session open writes a hash-chained receipt (sink
browse-tunnel) to~/.gstack/security/egress.jsonlBEFORE ngrok forwards anything. Fail-closed: if the receipt can't be written, the tunnel refuses to start. Audit withbin/gstack-egress list/bin/gstack-egress verify. - All agent activity is logged with attribution (clientId).
Known non-goal (tracked as #1136): on Windows, the cookie-import-browser path launches Chrome with --remote-debugging-port=<random>. With App-Bound Encryption v20, a same-user local process can connect to that port and exfiltrate decrypted v20 cookies — an elevation path relative to reading the SQLite DB directly. Fix direction is --remote-debugging-pipe instead of TCP.
Same-Machine Shortcut
If both agents are on the same machine, skip the copy-paste:
$B pair-agent --local openclaw # writes to ~/.openclaw/skills/gstack/browse-remote.json
$B pair-agent --local codex # writes to ~/.codex/skills/gstack/browse-remote.json
$B pair-agent --local cursor # writes to ~/.cursor/skills/gstack/browse-remote.json
No tunnel needed. Uses localhost directly.
ngrok Tunnel Setup
For remote agents on different machines:
- Sign up at ngrok.com (free tier works)
- Copy your auth token from the dashboard
- Save it:
echo 'NGROK_AUTHTOKEN=your_token' > ~/.gstack/ngrok.env - Optionally claim a stable domain:
echo 'NGROK_DOMAIN=your-name.ngrok-free.dev' >> ~/.gstack/ngrok.env - Start with tunnel:
BROWSE_TUNNEL=1 $B restart - Run
$B pair-agent— it will use the tunnel URL automatically