Files
gstack/docs/REMOTE_BROWSER_ACCESS.md
T
85fd9db554 v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot (#2665)
* fix(pairing): reject reserved clientId 'root' at all token writers

'root' is the sentinel checkScope/checkDomain/checkRate and the server
command gate use for the omnipotent caller, so a scoped token carrying it
bypasses every enforcement path. Add ReservedClientIdError + a shared
assertValidClientId; createToken/createSetupKey throw, restoreRegistry
skips-and-logs (a corrupt state file must not brick boot). /pair and /token
surface it as a named 400, and the CLI fast-fails --client root.

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

* fix(pairing): release tab ownership on revoke

tabOwnership cleared only on tab close, so after DELETE /token a same-name
re-pair inherited the revoked agent's authenticated tabs (own-only access
keys on owner === clientId). Add BrowserManager.releaseClientTabs and run it
unconditionally in DELETE /token (ownership outlives the token, so an
expired-token client can still own tabs); 404 only when both nothing was
revoked and nothing released. Response now carries tabs_released.

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

* v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot

POST /pair minted a new setup key but never touched the agent's live
session, so re-pairing --client X --restrict read while X was connected (or
whose 5-min key expired unexchanged) left the original full-access session,
eval included, alive up to 24h.

A reducing re-pair (fewer scopes, tighter domains, lower rate, stricter tab
policy) now revokes the live session and releases its tabs before minting
the new key (grantReducesAccess + revokeClientFully; superseded in the
response). Non-reducing re-pairs keep the session and only drop stale PENDING
setup keys, so a broaden/refresh never strands a working agent and a
narrowing re-pair issued before the agent connects can't leave the old broad
key exchangeable. Revoke happens before mint (revokeToken deletes all of a
client's tokens). CLI prints a version-skew-safe supersede notice and warns
when a re-pair-shaped call omits --client. Docs + CHANGELOG + VERSION.

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

* fix(pairing): harden re-pair per adversarial review

Adversarial review of the diff found four issues, now fixed:
- Validate the requested grant BEFORE the supersede revoke: a reducing
  re-pair with a bad scope/rate no longer destroys the live session and
  then fails to mint a replacement (assertValidTokenOptions runs up front).
- A re-pair with no live session releases tabs orphaned by an expired
  incarnation, closing the tab-inheritance gap /pair had (DELETE /token
  already released unconditionally).
- Test the DELETE /token revoked=0/tabs>0 path and the /pair orphaned-tab
  release at the handler level (HTTP e2e can't, headless owns no tabs).
- Test the CLI --client root fast-fail; fix its null-guard (parseFlag
  returns null when --client is absent).

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

---------

Co-authored-by: Garry Tan <garry@ycombinator.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:32:26 -07:00

12 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

  1. User runs $B pair-agent (or /pair-agent in Claude Code)
  2. Server creates a one-time setup key (expires in 5 minutes)
  3. User copies the instruction block into the other agent's chat
  4. Remote agent runs POST /connect with the setup key
  5. Server returns a scoped session token (24h default)
  6. Remote agent creates its own tab via POST /command with newtab
  7. Remote agent browses using POST /command with 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:

  1. Run snapshot -i to get an interactive snapshot with labeled elements
  2. The snapshot returns text like:
    [Page Title]
    @e1 [link] "Home"
    @e2 [button] "Sign In"
    @e3 [input] "Search..."
    
  3. Use the @e refs 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.

To tighten an already-paired agent, re-pair it with the same --client name and the narrower --restrict/--domain: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without --client mints a new agent instead. root is a reserved client name.

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 newtab before 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. /command over 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/storage stay blocked over the tunnel by the command allowlist. Use --restrict for less-trusted agents.
  • Tokens can be revoked instantly: $B tunnel revoke agent-name deletes the session plus any pending setup keys and verifies against the live agent list. $B tunnel agents shows who's paired (pending setup keys included). $B stop clears 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 /welcomeGSTACK_SLUG must 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.jsonl with 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.jsonl BEFORE ngrok forwards anything. Fail-closed: if the receipt can't be written, the tunnel refuses to start. Audit with bin/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:

  1. Sign up at ngrok.com (free tier works)
  2. Copy your auth token from the dashboard
  3. Save it: echo 'NGROK_AUTHTOKEN=your_token' > ~/.gstack/ngrok.env
  4. Optionally claim a stable domain: echo 'NGROK_DOMAIN=your-name.ngrok-free.dev' >> ~/.gstack/ngrok.env
  5. Start with tunnel: BROWSE_TUNNEL=1 $B restart
  6. Run $B pair-agent — it will use the tunnel URL automatically