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

212 lines
12 KiB
Markdown

# 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](../ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) 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).
```json
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.
```json
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 `/welcome` — `GSTACK_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:
```bash
$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](https://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