* feat(loadouts): Opus 5.5 lite loadout with explicit model detection (GSTA-161)
modules/loadouts.json gains the lite loadout (eleven coaching preamble
pieces dropped; upgrade-check and brain-sync kept) and a models map that
sends only claude-opus-5-5 (and its dated snapshots) to it. Detection is
explicit: Capy passes --model to the installer, Claude Code opts in with
./setup --loadout lite; anything unmapped, invalid or unreadable serves full.
The checkout always stays the full render.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(loadouts): Opus 5.5 lite, detection order, Capy SKILL_ROOT, setup --loadout; troubleshooting anchors; changelog fragment (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(loadouts): claude_loadout read through CONFIG_BIN; capy skill context ceiling for the --model line (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (prune keeps a reused loadout folder, GSTACK_LOADOUT_SKILL_ROOT, claude_loadout guard test) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (Capy skill passes the model as GSTACK_MODEL so an older installer ignores it) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (prune only owned, unused folders; full root from the canonical Claude install; fallback reason and source) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: loadout source example, install and resolve commands, Capy launch-file note (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: classify autoplan-ceo-only in the Codex host metadata
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: run autoplan-ceo-only in the marathon lane
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: changelog entry for /autoplan --ceo-only
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: count the autoplan-ceo-only paid file in the shard inventory
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat: gstack-autoplan next --ceo-only runs the CEO review alone and ends reviewed (GST-537)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: budget and close recover a budget-stopped CEO-only run; reviewed is at rest for checkpoints
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: CEO-only finish leaves owner-only cap items undecided, refuses marker phase flags and a plan at the report path, and replaces ceo-review.md atomically
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: CEO_ONLY_CONFLICT covers a plan linked to the report path
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(browse): failing-first device and touch tests (GSTA-227 F3, #3058)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: the report-path guard keeps the lexical check, skips safe hard links and compares bigint file ids
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: per-session tabs for parallel agents; tab pins stop moving the shared active tab (#1961)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: keep the server.ts tab-pin anchor comment for source guards
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(browse): per-tab phone emulation and touch input ($B device, tap, swipe, pinch, touch; #3058, GSTA-227 F3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: copy lib/result-codes-autoplan.ts into the fixtures that copy the result-code tables, and keep it out of derived closures
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(qa, aside): touch pointer in /qa, Aside touch recipe verified on Aside 1.26, report Phones row (GSTA-227 F3, #3058)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: drop the sessions paragraph from /browse to stay inside the parity cap
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: F3 phone emulation and touch entry
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: hide the ps window in the session harness walk
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: the decisions.jsonl status list names auto_kept
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(browse): session cap, LRU eviction, headed release and scoped auto-session rows
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: sessions keep tab <id> picks, sidebar claude stays on the user's tab, pinned rebuild and state load rebind the scope
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (touch cancel with no finger held is a no-op)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: Step 11 review fixes (headed tab adoption, pinned viewport reset, CLI retry keeps --session, rebuild id remap, --json notice, evicted-session reset, device kept after annotated captures, render opts out of sessions, session: client ids reserved)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: headed viewport auto keeps the user's active tab; render's closetab opts out of sessions
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: sessions, phone emulation and touch in BROWSER.md; session: client ids reserved; TODOS #1961/#3058 shipped
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: copy lib/result-codes-browse.ts into the fixtures that copy the result-code modules
lib/result-codes.ts imports ./result-codes-browse, so fixtures with an explicit
lib list failed with 'Cannot find module'. pr-dependencies lists it with the other
reason-code modules as selection data. GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat: gstack-clean reclaims per-repo .gstack scratch and logs, dry run by default (#2627)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: name the Aside touch recipe's coordinates <tap-x>/<tap-y> with a numeric grammar
The generated-bash lint (INV-3) rejected the free-text <x>/<y> placeholders. GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): leave hard-linked and unreadable logs alone; cut through a no-follow descriptor checked against the evaluated inode
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): tests that expect tmp removal see a quiet ps, so a codex elsewhere on the host cannot flake them
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): an unlistable tmp/ skips its group; liveness asked once per group on apply; BROWSER.md says which logs reset at daemon start
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): write output synchronously so --json reaches a pipe whole; test fixes for root, wrapper git and the ps stub
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): every change rechecks the verified folder and the measured file; full writes; EPIPE ends output quietly
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): the early-close test checks the bin's own exit status
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: list gstack-clean among the standalone CLIs
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): copied-install fixtures and the dependency list carry lib/result-codes-clean.ts
The remote pregate failed 11 cases in gstack-doctor, plan-tune-cathedral and
timeline-stop-hook-slug: each copies an explicit list of lib/result-codes-*.ts
files into a temp install, and lib/result-codes.ts now imports
./result-codes-clean. Same lists GSTA-168 extended for result-codes-eval.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: regenerate gstack-doctor-components.sh for the gstack-clean result codes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* qa: fold the touch pointer into the Phases sentence to fit the parity size cap after the main merge
qa's union was 95988 bytes (ratio 1.142 > maxSizeRatio 1.142, train 75). Now 95975 (1.1419). GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.10.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): classify gstack-tasks rows files (tasks-*) as kept, after main added the producer
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.20.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (restore the lib/result-codes-plan.ts ratchet row the base merge dropped)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): the producer scan ends a name at a colon, so prose after a path names the file (train 78)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.30.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* outside-voice-api: caller receipts, stop_reason, OpenAI max_completion_tokens
A caller can write its own egress receipt (sink, consent, payload class);
the default stays the outside-voice receipt, so existing callers send and
record the same bytes. The result carries the provider's stop reason, and
an OpenAI request gets max_completion_tokens only when the caller sets an
output limit (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan gate: item classes and the owner's answer provenance
Gate items carry item_class, ruleKept() names the keep-direction pick, and
answered decision rows record owner_mode, owner_named, rec_hash and
answered_role parsed from the owner's own words (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan: shadow-mode decider module, frozen prompt and spend accounting
The switch (off by default, fixed at init), the lease, one model call per
gate with a page retry and a truncation retry under one deadline, the
once-per-revision record with its journaled sha256, the 10% audit mark,
and shadow-labeled ledger rows that count against the cap, show as
shadow= on GATE_SPEND and stay out of the per-voice price (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* gstack-autoplan: shadow every published gate, the shadow export, config and docs
Every driver command that prints a gate records the shadow first and
checkpoints after it; answer waits for a live lease and records a dead
one retrospectively; gstack-autoplan shadow exports answered revisions
for taste-replay R2 (SHADOW_BEFORE_ANSWER otherwise); gstack-config
autoplan_shadow / autoplan_shadow_model; gstack-artifact validate checks
the record's hash (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: shadow mode for the CEO-only decider (GST-537 PR 3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan shadow: /ship review fixes
- register test/fixtures/shadow-fake-model.ts in FREE_FIXTURES (free-fixtures inventory failed)
- withRunLock: count dead-owner reclaims apart from waitMs iterations, so a long wait can still reclaim
- buildPage: fill the decider template in one pass, so slot names inside the plan stay plan text
- revise regate: the shared publish step prints the GATE and ESCALATE lines once
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan shadow: reply files never follow a link; the retry reserves against the current cap
- a model reply is written by temp file and rename, so a link at the reply path is replaced, not written through
- each shadow reservation reads the run's current spend cap, so a budget change during the call is not undone
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* outside-voice-api: write the egress receipt under the caller's env
callModel resolved the receipt's state root from process.env even when the caller passed env, so the shadow
tests wrote to the real ~/.gstack (the free-suite home-containment guard caught it). The receipt now uses
req.env; the shadow tests give the fake model a private HOME, GSTACK_HOME and GSTACK_STATE_ROOT, and the receipt
test no longer sets process.env to pass.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): CEO-only runs leave main's open Critical/High finding items to the owner (no decision row)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): shadow pages carry main's finding items; a finding item is never ceo_eligible
After merging #3767 (main 90a170c), open Critical/High findings are gate items
(item_class finding). The shadow records one row per gate item, so its pages now
include them. A CEO-only run leaves those items to the owner, so their rows are
ceo_eligible false (rule_pick was already null). The shadow fixtures follow
main's gate (items, counts, "Do it" tokens); the two merge adapters follow
main's doNext(out, rest, price) and its per-voice reservation (--estimate-usd).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.61.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(investigate): drop the five-phase step procedure (GST-938)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: GST-938 fragment wording
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(investigate): keep the web-search sanitize guard; update BROWSER.md and docs/skills.md (review pass 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: GST-938 wording
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(investigate): pin the web-search guard; changelog size (adversarial review)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): classify restamp-staging.json, the restamp producer main gained after #3779's base (wave browse)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (wave browse): scoped newtab keeps a concurrent tab switch; shadow validate catches a deleted record; a failed shadow publish is journaled
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: a failed shadow publish journals under the run lock and never throws (wave browse review pass 2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(unattended): shadow publish failures and the missing-record validate check (wave browse docs audit)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: wave browse integrator fixes fragment
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: shadow reader dedupes by run folder, so two runs started in the same second both count (wave browse CI free-suite 16)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: regenerate doctor result anchors in generator order (wave browse rebuild without #3818)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: name the wave fragment after the rebuilt wave
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.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 and names starting with session: (kept for local agent sessions) are reserved client names.
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