Files
gstack/docs/REMOTE_BROWSER_ACCESS.md
T
0c87a553c7 v1.91.155.0 fix(browse): a busy tab no longer blocks every command with "Daemon busy" (port of #3250) (#3301)
* fix(browse): /health status is daemon liveness; active tab reported apart (#3247)

/health answered `status` from a page.evaluate('1') race on the active
tab, so one page stuck in a long script made the whole daemon read as
unhealthy. The CLI gates every command on that answer, and its 2000 ms
fetch timeout equalled the server's 2000 ms race, so it could not even
read the reply: status, tabs, tab, newtab and stop all failed with
"Daemon busy" until --force-restart threw the session away.

- BrowserManager.isHealthy() is liveness only (Chromium connected).
- probeActiveTab() reports { id, responsive }, bounded by
  TAB_PROBE_TIMEOUT_MS (1s); /health returns it as `activeTab`.
- `status` adds a line naming a not-responding active tab and the
  commands that leave it.
- The CLI reads /health through one helper whose timeout,
  HEALTH_REQUEST_TIMEOUT_MS (2s), stays above the probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL
(cherry picked from commit 322d255354)

* fix(browse): bound per-tab reads in `tabs` and `state save` (#3247)

A renderer stuck in a long script never answers page.title() or
page.evaluate(). `tabs` awaited each title in turn and `state save`
read each tab's storage unbounded, so both hung on one busy tab even
though neither needs it.

- getTabListWithTitles() reads titles in parallel, each bounded by
  TAB_PROBE_TIMEOUT_MS; a tab that misses it is listed as
  "(not responding)".
- saveState() skips the storage of a tab that does not answer within
  5s, the same outcome as a tab whose read fails.

busy-active-tab.test.ts holds a real Chromium tab in a busy loop next
to an idle one and checks /health, tabs, status, saveState and
closetab against it. On main, /health takes 2004 ms and says
unhealthy, `tabs` hangs past the test timeout and `status` never
names the tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL
(cherry picked from commit d010d7b243)

* fix(browse): name the busy tab when a command times out (#3247)

A command that needs a stuck tab still waits out the CLI's 30s reply
timeout, and the report should say why. It never ran: Bun rejects an
AbortSignal.timeout() fetch with TimeoutError, the generic branch only
matched AbortError, so a timeout fell through to "[browse] The
operation timed out."

- isFetchTimeout() accepts both names; the cookie-import branch uses it
  too.
- commandTimeoutReport() reads /health: when `activeTab.responsive` is
  false it names the tab and points at `browse tab <id>` and
  `browse closetab <id>`; a live daemon otherwise keeps the
  busy-not-restarting line and a dead one the plain timeout line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL
(cherry picked from commit ef70b7d880)

* docs(browse): /health liveness vs active-tab responsiveness (#3247)

BROWSER.md's busy-vs-dead lifecycle note, ARCHITECTURE.md's daemon
model and the REMOTE_BROWSER_ACCESS /health reference now say that
`status` is daemon liveness, that `activeTab` reports the active tab
separately, and which commands keep working while a tab is busy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL
(cherry picked from commit 3dbb256796)

* fix(browse): one pending probe per stuck tab, not one per poll (#3247)

Review on #3250: the 1s race stops waiting but leaves page.evaluate()
queued in the stuck renderer, so every /health poll (the extension
polls every 10s), `status` and `tabs` call added another pending read,
all drained at once when the page recovered. Playwright cannot cancel
an in-flight evaluate, so the active-tab probe and the `tabs` title
read now join the page's pending read of the same kind instead of
starting a new one.

busy-active-tab.test.ts counts page.evaluate calls across three probes
of the stuck tab: 3 before this change, at most 1 after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL
(cherry picked from commit 19fc9b9709)

* fix(browse): keep a busy tab's last good storage in saved state (#3247)

Review on #3250: with BROWSE_PERSIST_STATE=1 the periodic snapshot used
to hang on a stuck tab, so session-state.json kept its last good copy.
The 5s storage bound let the snapshot finish and write `storage: null`
for that tab over it, so a restart lost its localStorage and
sessionStorage even with persistence on.

saveState() now records the storage each page answers with, together
with its origin. A read that times out returns that last good storage
when the origin still matches, and null otherwise. A failed read stays
null, as before. The storage read also joins the page's pending read
instead of queueing a new one on every persist tick.

busy-active-tab.test.ts takes a good snapshot, hangs the tab, and checks
that persistSessionState() still writes its localStorage. Live, with
BROWSE_PERSIST_STATE=1 and a 2s interval, session-state.json kept
{"probe":"kept"} for the busy tab, and its savedAt stayed current
through 12s of busy page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL
(cherry picked from commit 60cbaebee5)

* docs(changelog): busy-tab lockup fix from #3250 under [Unreleased]

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Andrei Vershinin <vershinin@intelas.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-19 21:30:33 +00:00

12 KiB

Remote Browser Access — How to Pair With a GStack Browser

Scope: /pair-agent shares gstack's own browser engine, the fallback that runs when the Aside browser is absent (Linux, Windows, a closed Aside app). On a Mac with Aside open, every skill drives Aside instead, and there is no shared daemon to pair with — each agent that drives Aside opens its own tabs. See BROWSER.md for when the fallback kicks in.

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, activeTab, tabs, mode, uptime. status is daemon liveness; activeTab ({ id, responsive }, or null with no page) says whether the active tab's main thread answered within 1s. 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:

$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