Files
gstack/pair-agent/SKILL.md.tmpl
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

367 lines
13 KiB
Cheetah

---
name: pair-agent
preamble-tier: 2
version: 0.1.0
description: |
Pair a remote AI agent with your browser. One command generates a setup key and
prints instructions the other agent can follow to connect. Works with OpenClaw,
Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent
gets its own tab with full page access by default (the pairing ceremony is the
trust boundary; --restrict narrows it).
Use when asked to "pair agent", "connect agent", "share browser", "remote browser",
"let another agent use my browser", or "give browser access". (gstack)
voice-triggers:
- "pair agent"
- "connect agent"
- "share my browser"
- "remote browser access"
triggers:
- pair with agent
- connect remote agent
- share my browser
allowed-tools:
- Bash
- Read
- AskUserQuestion
---
{{PREAMBLE}}
# /pair-agent — Share Your Browser With Another AI Agent
You're sitting in Claude Code with a browser running. You also have another AI agent
open (OpenClaw, Hermes, Codex, Cursor, whatever). You want that other agent to be
able to browse the web using YOUR browser. This skill makes that happen.
## How it works
Your gstack browser runs a local HTTP server. This skill creates a one-time setup key,
prints a block of instructions, and you paste those instructions into the other agent.
The other agent exchanges the key for a session token, creates its own tab, and starts
browsing. Each agent gets its own tab. They can't mess with each other's tabs.
The setup key expires in 5 minutes and can only be used once. If it leaks, it's dead
before anyone can abuse it. The session token lasts 24 hours.
**Same machine:** If the other agent is on the same machine (like OpenClaw running
locally), you can skip the copy-paste ceremony and write the credentials directly to
the agent's config directory.
**Remote:** If the other agent is on a different machine, you need an ngrok tunnel.
The skill will tell you if one is needed and how to set it up.
{{BROWSE_SETUP}}
## Step 1: Check prerequisites
```bash
$B status 2>/dev/null
```
If the browse server is not running, start it:
```bash
$B goto about:blank
```
This ensures the server is up and healthy before pairing.
## Step 2: Ask what they want
Use AskUserQuestion:
> Which agent do you want to pair with your browser? This determines the
> instructions format and where credentials get written.
Options:
- A) OpenClaw (local or remote)
- B) Codex / OpenAI Agents (local)
- C) Cursor (local)
- D) Another Claude Code session (local or remote)
- E) Something else (generic HTTP instructions — use this for Hermes)
Based on the answer, set `TARGET_HOST`:
- A → `openclaw`
- B → `codex`
- C → `cursor`
- D → `claude`
- E → generic (no host-specific config)
## Step 3: Local or remote?
Use AskUserQuestion:
> Is the other agent running on this same machine, or on a different machine/server?
>
> **Same machine** skips the copy-paste ceremony. Credentials are written directly to
> the agent's config directory. No tunnel needed.
>
> **Different machine** generates a setup key and instruction block. If ngrok is
> installed, the tunnel starts automatically. If not, I'll walk you through setup.
>
> RECOMMENDATION: Choose A if the agent is local. It's instant, no copy-paste needed.
Options:
- A) Same machine (write credentials directly)
- B) Different machine (generate instruction block for copy-paste)
## Step 4: Execute pairing
**Live-daemon consent (one-way door).** Pairing can relaunch the browser
daemon; a relaunch KILLS the running headless daemon — open tabs, cookies,
and logged-in sessions die with it. The CLI honors the iron rule (only an
explicit `--force-restart` may kill a live daemon), so check first:
```bash
$B status 2>/dev/null | head -5
```
If a daemon is running, ask via AskUserQuestion (one-way door — lost
tabs/cookies/logins cannot be recovered):
> "A headless browser daemon is live (tabs and logins may be active). Pairing
> headed requires relaunching it — everything in the current daemon is lost.
>
> RECOMMENDATION: Choose B unless the remote agent specifically needs a
> visible browser window; pairing works against the existing daemon."
Options:
- A) Relaunch (pass `--force-restart`; current tabs/cookies/logins are lost)
- B) Keep the live daemon (recommended — pair against it as-is)
Only pass `--force-restart` to the commands below after an explicit A. Never
default to A on a vague reply — this is a destructive confirmation.
### If same machine (option A):
Run pair-agent with --local flag:
```bash
$B pair-agent --local TARGET_HOST
```
Replace `TARGET_HOST` with the value from Step 2 (openclaw, codex, cursor, etc.).
If it succeeds, tell the user:
"Done. TARGET_HOST can now use your browser. It will read credentials from the
config file that was written. Try asking it to navigate to a URL."
If it fails (host not found, write permission error), show the error and suggest
using the generic remote flow instead.
### If different machine (option B):
**Consent gate (once per machine).** The tunnel exposes this browser beyond
the machine, so it is OFF until the user opts in — the daemon refuses
`/tunnel/start` and `BROWSE_TUNNEL=1` otherwise. Check the standing consent:
```bash
~/.claude/skills/gstack/bin/gstack-config get pair_agent 2>/dev/null || echo "unset"
```
If the value is not `on`, ask via AskUserQuestion (one-way-door posture —
this opens a path from the internet to the local browser):
> "Remote pairing runs an ngrok tunnel from the internet to this machine's
> browser (locked to a 26-command allowlist + scoped token, but still an
> exposure). Enable pair-agent on this machine?"
Options: A) Enable — run `~/.claude/skills/gstack/bin/gstack-config set pair_agent on`, confirm it reads back `on`, and continue. B) No — stop here; local pairing (option A above) still works.
If the value is already `on`, say nothing and continue — consent stands until
`gstack-config set pair_agent off`.
Then detect ngrok status:
```bash
which ngrok 2>/dev/null && echo "NGROK_INSTALLED" || echo "NGROK_NOT_INSTALLED"
ngrok config check 2>/dev/null && echo "NGROK_AUTHED" || echo "NGROK_NOT_AUTHED"
```
**If ngrok is installed and authed:** Just run the command. The CLI will auto-detect
ngrok, start the tunnel, and print the instruction block with the tunnel URL:
```bash
$B pair-agent --client TARGET_HOST
```
Default access already includes JS execution. To also grant browser-wide
control (stop, restart, disconnect):
```bash
$B pair-agent --control --client TARGET_HOST
```
For a less-trusted agent, narrow the scopes instead:
```bash
$B pair-agent --restrict read --client TARGET_HOST # read-only
$B pair-agent --restrict "read,write" --client TARGET_HOST # no JS, no cookies
```
**CRITICAL: You MUST output the full instruction block to the user.** The command
prints everything between ═══ lines. Copy the ENTIRE block verbatim into your
response so the user can copy-paste it into their other agent. Do NOT summarize it,
do NOT skip it, do NOT just say "here's the output." The user needs to SEE the block
to copy it. Output it inside a markdown code block so it's easy to select and copy.
Then tell the user:
"Copy the block above and paste it into your other agent's chat. The setup key
expires in 5 minutes."
**If ngrok is installed but NOT authed:** Walk the user through authentication.
SECURITY: the ngrok authtoken must NEVER pass through this chat, a Bash tool
call, or shell history — a token pasted here lands in the transcript (and
anything the transcript syncs to). The user runs the auth command in their
OWN terminal; you only verify the result.
Tell the user:
"ngrok is installed but not logged in. Let's fix that — in your own terminal
(not here; the token should never enter this chat):
1. Go to https://dashboard.ngrok.com/get-started/your-authtoken
2. Copy your auth token
3. In YOUR terminal, run: ngrok config add-authtoken <paste your token>
4. Tell me 'done' when finished."
STOP here and wait for the user to say they've run it. Do NOT accept a pasted
token; if the user pastes one anyway, tell them to rotate it at
https://dashboard.ngrok.com (it's now in the transcript) and re-auth in their
terminal with the new one.
When they say done, verify without touching the token:
```bash
ngrok config check 2>/dev/null && echo "NGROK_AUTHED" || echo "NGROK_NOT_AUTHED"
```
If `NGROK_AUTHED`: retry `$B pair-agent --client TARGET_HOST`.
If still `NGROK_NOT_AUTHED`: ask them to re-run the command in their terminal.
**If ngrok is NOT installed:** Walk the user through installation:
Tell the user:
"To connect a remote agent, we need ngrok (a tunnel that exposes your local
browser to the internet securely).
1. Go to https://ngrok.com and sign up (free tier works)
2. Install ngrok:
- macOS: `brew install ngrok`
- Linux: `snap install ngrok` or download from ngrok.com/download
3. Auth it: `ngrok config add-authtoken YOUR_TOKEN`
(get your token from https://dashboard.ngrok.com/get-started/your-authtoken)
4. Come back here and run `/pair-agent` again."
STOP here. Wait for the user to install ngrok and re-invoke.
## Step 5: Verify connection
After the user pastes the instructions into the other agent, wait a moment then check:
```bash
$B status
```
Look for the connected agent in the status output. If it appears, tell the user:
"The remote agent is connected and has its own tab. You'll see its activity in the
side panel if you have GStack Browser open."
## What the remote agent can do
Default access is read+write+admin+meta. The trust boundary is the pairing
ceremony, not the scope:
- Navigate to URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
Remote agents go through the tunnel command allowlist: `eval` works, but the
`js`, `cookies`, and `storage` commands are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all four.
With --restrict (`--restrict read`, `--restrict "read,write"`):
- Sandboxed sessions: read-only, or read+write with no JS, cookie, or storage
access. Pair this way when the remote agent will read untrusted web content:
a trusted agent can be prompt-injected by pages it reads, and scope caps the
blast radius (eval works over the tunnel).
- `--restrict` never grants `control`; that scope stays behind --control.
- To tighten an agent that is ALREADY paired, re-pair it with the **same
`--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair
revokes the previous session immediately and releases its tabs — the agent
must reconnect with the new key, so the old wide access does not linger.
Re-pairing without `--client` mints a brand-new agent and leaves the old one
untouched. Broadening or refreshing keeps the working session (no outage).
- `root` is a reserved `--client` name (it would bypass all scope enforcement).
With --control (--admin is the legacy alias):
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
- Only for agents you fully trust.
## Troubleshooting
**"Tab not owned by your agent"** — The remote agent tried to interact with a tab
it didn't create. Tell it to run `newtab` first to get its own tab.
**"Domain not allowed"** — The token has domain restrictions. Re-pair with the
same `--client` name and broader (or no) `--domain`. A broadening re-pair keeps
the working session; a narrowing one revokes it immediately.
**"Rate limit exceeded"** — The agent is sending > 10 requests/second. It should
wait for the Retry-After header and slow down.
**"Token expired"** — The 24-hour session expired. Run `/pair-agent` again to
generate a new setup key.
**Agent can't reach the server** — If remote, check the ngrok tunnel is running
(`$B status`). If local, check the browse server is running.
## Platform-specific notes
### OpenClaw / AlphaClaw
OpenClaw agents use the `exec` tool instead of `Bash`. The instruction block uses
`exec curl` syntax which OpenClaw understands natively. When using `--local openclaw`,
credentials are written to `~/.openclaw/skills/gstack/browse-remote.json`.
### Codex
Codex agents can execute shell commands via `codex exec`. The instruction block's
curl commands work directly. When using `--local codex`, credentials are written
to `~/.codex/skills/gstack/browse-remote.json`.
### Cursor
Cursor's AI can run terminal commands. The instruction block works as-is.
When using `--local cursor`, credentials are written to
`~/.cursor/skills/gstack/browse-remote.json`.
## Revoking access
To disconnect a specific agent:
```bash
$B tunnel revoke AGENT_NAME
```
The command deletes every token for that agent (the session and any pending
setup keys) and re-reads the agent list to prove it's gone.
See who's paired:
```bash
$B tunnel agents
```
Unexchanged setup keys show as "(pending)"; `tunnel revoke` removes them too.
To disconnect ALL agents at once, stop the daemon. Scoped tokens live in
daemon memory and never survive a restart; the next command boots a fresh
daemon with a new root token:
```bash
$B stop
```