From fafe68b44c563883903346047ce21671698d7295 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 4 Apr 2026 23:18:32 -0700 Subject: [PATCH] feat: pair-agent CLI command + instruction block generator One command to pair a remote agent: $B pair-agent. Creates a setup key via POST /pair, prints a copy-pasteable instruction block with curl commands. Smart tunnel fallback (tunnel URL > auto-start > localhost). Flags: --for HOST, --local HOST, --admin, --client NAME. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/cli.ts | 189 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 6e0d42f9..fb7f34ae 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -447,6 +447,188 @@ async function sendCommand(state: ServerState, command: string, args: string[], } } +// ─── Pair-Agent DX ───────────────────────────────────────────── + +interface InstructionBlockOptions { + setupKey: string; + serverUrl: string; + scopes: string[]; + expiresAt: string; +} + +/** Pure function: generate a copy-pasteable instruction block for a remote agent. */ +export function generateInstructionBlock(opts: InstructionBlockOptions): string { + const { setupKey, serverUrl, scopes, expiresAt } = opts; + const scopeDesc = scopes.includes('admin') + ? 'read + write + admin access (can execute JS, read cookies, access storage)' + : 'read + write access (cannot execute JS, read cookies, or access storage)'; + + return `\ +${'='.repeat(59)} + REMOTE BROWSER ACCESS — paste this into your other agent +${'='.repeat(59)} + +You have access to a remote browser controlled via HTTP API. +This setup key expires in 5 minutes. + +STEP 1 — Exchange the setup key for a session token: + + curl -s -X POST \\ + -H "Content-Type: application/json" \\ + -d '{"setup_key": "${setupKey}"}' \\ + ${serverUrl}/connect + + You'll get back: {"token": "gsk_sess_...", "expires": "...", "scopes": [...]} + Save that token. Use it for all subsequent requests. + +STEP 2 — Create your own tab: + + curl -s -X POST \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"command": "newtab", "args": ["https://example.com"]}' \\ + ${serverUrl}/command + + You'll get back: {"tabId": N, ...} + Include "tabId": N in all subsequent commands. + +STEP 3 — Use the browser. Send commands as POST /command: + + curl -s -X POST \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"command": "snapshot", "args": ["-i"], "tabId": }' \\ + ${serverUrl}/command + +AVAILABLE COMMANDS: + Navigate: {"command": "goto", "args": ["URL"], "tabId": N} + Read page: {"command": "snapshot", "args": ["-i"], "tabId": N} + Full text: {"command": "text", "args": [], "tabId": N} + Screenshot: {"command": "screenshot", "args": ["/tmp/screen.png"], "tabId": N} + Click: {"command": "click", "args": ["@e3"], "tabId": N} + Fill form: {"command": "fill", "args": ["@e5", "value"], "tabId": N} + Go back: {"command": "back", "args": [], "tabId": N} + List tabs: {"command": "tabs", "args": []} + +SCOPES: This token has ${scopeDesc}. +${scopes.includes('admin') ? '' : `To request admin access, ask the user to re-run pair-agent with --admin.\n`} +SESSION: Token expires ${expiresAt}. The user can revoke it +anytime with: $B tunnel revoke + +IF SOMETHING GOES WRONG: + 401 Unauthorized → Token expired or revoked. Ask the user + to run pair-agent again. + 403 Forbidden → Command not in your scope, or tab not owned + by you. Use newtab first. + 429 Too Many Requests → Sending > 10 requests/second. + Wait for the Retry-After header. + +${'='.repeat(59)}`; +} + +function parseFlag(args: string[], flag: string): string | null { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) return null; + return args[idx + 1]; +} + +function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag); +} + +async function handlePairAgent(state: ServerState, args: string[]): Promise { + const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`; + const admin = hasFlag(args, '--admin'); + const localHost = parseFlag(args, '--local'); + + // Call POST /pair to create a setup key + const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.token}`, + }, + body: JSON.stringify({ + clientId: clientName, + admin, + }), + signal: AbortSignal.timeout(5000), + }); + + if (!pairResp.ok) { + const err = await pairResp.text(); + console.error(`[browse] Failed to create setup key: ${err}`); + process.exit(1); + } + + const pairData = await pairResp.json() as { + setup_key: string; + expires_at: string; + scopes: string[]; + tunnel_url: string | null; + server_url: string; + }; + + // Determine the URL to use + let serverUrl: string; + if (pairData.tunnel_url) { + serverUrl = pairData.tunnel_url; + } else { + // Check if ngrok is configured but tunnel isn't running + const ngrokEnvPath = path.join(process.env.HOME || '/tmp', '.gstack', 'ngrok.env'); + if (fs.existsSync(ngrokEnvPath) && !localHost) { + console.warn('[browse] ngrok is configured but tunnel is not running.'); + console.warn('[browse] Start the tunnel: BROWSE_TUNNEL=1 $B restart'); + console.warn('[browse] Using localhost for now (same-machine only).\n'); + } else if (!localHost) { + console.warn('[browse] No tunnel active. Instructions use localhost (same-machine only).\n'); + } + serverUrl = pairData.server_url; + } + + // --local HOST: write config file directly, skip instruction block + if (localHost) { + try { + // Resolve host config for the globalRoot path + const hostsPath = path.resolve(__dirname, '..', '..', 'hosts', 'index.ts'); + let globalRoot = `.${localHost}/skills/gstack`; + try { + const { getHostConfig } = await import(hostsPath); + const hostConfig = getHostConfig(localHost); + globalRoot = hostConfig.globalRoot; + } catch { + // Fallback to convention-based path + } + + const configDir = path.join(process.env.HOME || '/tmp', globalRoot); + fs.mkdirSync(configDir, { recursive: true }); + const configFile = path.join(configDir, 'browse-remote.json'); + const configData = { + url: serverUrl, + setup_key: pairData.setup_key, + scopes: pairData.scopes, + expires_at: pairData.expires_at, + }; + fs.writeFileSync(configFile, JSON.stringify(configData, null, 2), { mode: 0o600 }); + console.log(`Connected. ${localHost} can now use the browser.`); + console.log(`Config written to: ${configFile}`); + } catch (err: any) { + console.error(`[browse] Failed to write config for ${localHost}: ${err.message}`); + process.exit(1); + } + return; + } + + // Print the instruction block + const block = generateInstructionBlock({ + setupKey: pairData.setup_key, + serverUrl, + scopes: pairData.scopes, + expiresAt: pairData.expires_at || 'in 24 hours', + }); + console.log(block); +} + // ─── Main ────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2); @@ -678,6 +860,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: } const state = await ensureServer(); + + // ─── Pair-Agent (post-server, pre-dispatch) ────────────── + if (command === 'pair-agent') { + await handlePairAgent(state, commandArgs); + process.exit(0); + } + await sendCommand(state, command, commandArgs); }