* 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 commit322d255354) * 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 commitd010d7b243) * 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 commitef70b7d880) * 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 commit3dbb256796) * 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 commit19fc9b9709) * 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 commit60cbaebee5) * 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>
12 KiB
Remote Browser Access — How to Pair With a GStack Browser
Scope:
/pair-agentshares 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
- 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, 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:
- 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.
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
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