Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ Three escalating modes:
every command tick through in real time.
- **Pair-agent over a tunnel**. Daemon binds a second listener that ngrok
forwards. A remote agent (Codex, OpenClaw, Hermes, anything that can speak
HTTP) drives your local browser through a 26-command allowlist with a
HTTP) drives your local browser through a 25-command allowlist with a
scoped, single-use token.

---
Expand Down Expand Up @@ -1195,7 +1195,7 @@ resets once — a one-time in-product notice explains this.

Remote AI agents (Codex, OpenClaw, Hermes, anything that speaks HTTP) can
drive your local browser through an ngrok tunnel. The whole flow is gated
by a 26-command allowlist, scoped tokens, and a denial log.
by a 25-command allowlist, scoped tokens, and a denial log.

### How it works

Expand All @@ -1215,23 +1215,31 @@ When `pair-agent` activates, the daemon binds **two HTTP listeners**:
forwarded by ngrok. Used by your Claude Code, the Side Panel, anything
on your machine.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`). Locked allowlist —
`/connect`, `/command` (scoped tokens + 26-command browser-driving
`/connect`, `/command` (scoped tokens + 25-command browser-driving
allowlist), `/sidebar-chat`. ngrok forwards only this port.

Root tokens sent over the tunnel return 403. SSE endpoints use a 30-minute
HttpOnly `gstack_sse` cookie (never valid against `/command`).

### The 26-command tunnel allowlist
### The 25-command tunnel allowlist

Defined in `browse/src/server.ts` as `TUNNEL_COMMANDS`. Pure gate function
`canDispatchOverTunnel(command)` is exported for unit testing. Set:
Defined in `browse/src/routes/commands.ts` as `TUNNEL_COMMANDS`. Pure gate
function `canDispatchOverTunnel(command, args)` is exported for unit testing. Set:

```
goto, click, text, screenshot, html, links, forms, accessibility,
attrs, media, data, scroll, press, type, select, wait, eval,
attrs, media, data, scroll, press, type, select, wait,
newtab, tabs, back, forward, reload, snapshot, fill, url, closetab
```

The gate also reads the args, so over the tunnel a paired agent can't read
local files, can't choose a path on the daemon's disk, and can't make the
daemon save screenshot or snapshot images: `goto`/`newtab` take http(s) URLs only, `screenshot` needs
`--base64` (it returns the image) and cannot name an output path, `snapshot`
refuses `-a`/`--annotate`, `-H`/`--heatmap` and `-o`, and `--out` is
refused everywhere. `eval` is absent because it runs a file from the
daemon's disk.

Notably absent: `pair`, `unpair`, `cookies`, `setup`, `launch`, `restart`,
`stop`, `tunnel-start`, `token-mint`, `state`, `connect`, `disconnect`. A
remote agent that tries them gets a 403 plus a fresh entry in the denial log.
Expand Down
2 changes: 1 addition & 1 deletion browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,7 @@ COMMAND REFERENCE:
Navigate: {"command": "goto", "args": ["URL"], "tabId": N}
Snapshot: {"command": "snapshot", "args": ["-i"], "tabId": N}
Full text: {"command": "text", "args": [], "tabId": N}
Screenshot: {"command": "screenshot", "args": ["/tmp/s.png"], "tabId": N}
Screenshot: {"command": "screenshot", "args": ["--base64"], "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}
Expand Down
126 changes: 72 additions & 54 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,75 @@ import { resolveConfig } from './config';
import { filterSessionCookies } from './session-persist';
import type { Frame } from 'playwright';

/**
* Parse screenshot args. Priority: flags (--viewport, --clip, --base64,
* --selector) → selector (@ref, CSS) → output path. `outputPath` is set only
* when the caller named one; the tunnel gate refuses any that is.
*/
export function parseScreenshotArgs(args: string[]): {
outputPath?: string;
targetSelector?: string;
clipRect?: { x: number; y: number; width: number; height: number };
viewportOnly: boolean;
base64Mode: boolean;
} {
let outputPath: string | undefined;
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;
let base64Mode = false;

const remaining: string[] = [];
let flagSelector: string | undefined;
for (let i = 0; i < args.length; i++) {
const inline = /^(--clip|--selector)=(.*)$/s.exec(args[i]);
const flag = inline ? inline[1] : args[i];
const flagValue = () => (inline ? inline[2] : args[++i]);
if (flag === '--viewport') {
viewportOnly = true;
} else if (flag === '--base64') {
base64Mode = true;
} else if (flag === '--selector') {
flagSelector = flagValue();
if (!flagSelector) throw new Error('Usage: screenshot --selector <css> [path]');
} else if (flag === '--clip') {
const coords = flagValue();
if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
const parts = coords.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN))
throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
} else if (args[i].startsWith('--')) {
throw new Error(`Unknown screenshot flag: ${args[i]}`);
} else {
remaining.push(args[i]);
}
}

// Separate target (selector/@ref) from output path
for (const arg of remaining) {
// File paths containing / and ending with an image/pdf extension are never CSS selectors
const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg);
if (isFilePath) {
outputPath = arg;
} else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
targetSelector = arg;
} else {
outputPath = arg;
}
}

// --selector flag takes precedence; conflict with positional selector.
if (flagSelector !== undefined) {
if (targetSelector !== undefined) {
throw new Error('--selector conflicts with positional selector — choose one');
}
targetSelector = flagSelector;
}

return { outputPath, targetSelector, clipRect, viewportOnly, base64Mode };
}

/** Tokenize a pipe segment respecting double-quoted strings. */
function tokenizePipeSegment(segment: string): string[] {
const tokens: string[] = [];
Expand Down Expand Up @@ -440,61 +509,10 @@ export async function handleMetaCommand(

// ─── Visual ────────────────────────────────────────
case 'screenshot': {
// Parse priority: flags (--viewport, --clip, --base64) → selector (@ref, CSS) → output path
const page = bm.getPage();
let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;
let base64Mode = false;

const remaining: string[] = [];
let flagSelector: string | undefined;
for (let i = 0; i < args.length; i++) {
const inline = /^(--clip|--selector)=(.*)$/s.exec(args[i]);
const flag = inline ? inline[1] : args[i];
const flagValue = () => (inline ? inline[2] : args[++i]);
if (flag === '--viewport') {
viewportOnly = true;
} else if (flag === '--base64') {
base64Mode = true;
} else if (flag === '--selector') {
flagSelector = flagValue();
if (!flagSelector) throw new Error('Usage: screenshot --selector <css> [path]');
} else if (flag === '--clip') {
const coords = flagValue();
if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
const parts = coords.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN))
throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
} else if (args[i].startsWith('--')) {
throw new Error(`Unknown screenshot flag: ${args[i]}`);
} else {
remaining.push(args[i]);
}
}

// Separate target (selector/@ref) from output path
for (const arg of remaining) {
// File paths containing / and ending with an image/pdf extension are never CSS selectors
const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg);
if (isFilePath) {
outputPath = arg;
} else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
targetSelector = arg;
} else {
outputPath = arg;
}
}

// --selector flag takes precedence; conflict with positional selector.
if (flagSelector !== undefined) {
if (targetSelector !== undefined) {
throw new Error('--selector conflicts with positional selector — choose one');
}
targetSelector = flagSelector;
}
const parsed = parseScreenshotArgs(args);
const { clipRect, targetSelector, viewportOnly, base64Mode } = parsed;
const outputPath = parsed.outputPath ?? `${TEMP_DIR}/browse-screenshot.png`;

validateOutputPath(outputPath);

Expand Down
52 changes: 43 additions & 9 deletions browse/src/routes/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { type RouteEntry, jsonError } from './table';
import { canonicalizeCommand } from '../commands';
import { hasOutArg } from '../read-commands';
import { parseScreenshotArgs } from '../meta-commands';
import { parseSnapshotArgs } from '../snapshot';
import { emitActivity } from '../activity';
import { logTunnelDenial } from '../tunnel-denial-log';
import { sanitizeBody, stripLoneSurrogateEscapes } from '../sanitize';
Expand All @@ -24,7 +26,7 @@ export const TUNNEL_COMMANDS = new Set<string>([
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
'scroll', 'press', 'type', 'select', 'wait',
// Tab + navigation primitives operator docs and CLI hints already promised
'newtab', 'tabs', 'back', 'forward', 'reload',
// Read/inspect/write operators paired agents need to be useful
Expand All @@ -36,16 +38,48 @@ export const TUNNEL_COMMANDS = new Set<string>([
* Canonicalizes the command (so aliases hit the same set) and returns false
* for null/undefined input.
*
* `args` is consulted so an `--out` invocation (e.g. `eval --out <file>`) is
* NEVER tunnel-dispatchable: `--out` turns an otherwise-readable command into a
* local-disk WRITE, and the tunnel surface never grants disk-write capability to
* remote paired agents. Omitting `args` preserves the old command-only behavior.
* `args` is consulted so that over the tunnel a paired agent can't read local
* files, can't choose a path on the daemon's disk, and can't make the daemon
* save screenshot or snapshot images: `--out`, a screenshot without
* `--base64` or with an output path, a snapshot with `-a`/`-H`/`-o`, and
* goto/newtab to anything but an http(s) URL (file: reads) are refused.
* `eval` is absent from the allowlist because it runs a file from the
* daemon's disk. Omitting `args` (undefined) preserves the old command-only
* behavior; null, a non-array, or an array with a non-string element is refused.
*/
export function canDispatchOverTunnel(command: string | undefined | null, args?: string[]): boolean {
if (typeof command !== 'string' || command.length === 0) return false;
if (Array.isArray(args) && hasOutArg(args)) return false;
const cmd = canonicalizeCommand(command);
return TUNNEL_COMMANDS.has(cmd);
if (!TUNNEL_COMMANDS.has(cmd)) return false;
if (args === undefined) return true;
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string') || hasOutArg(args)) return false;
if (cmd === 'goto' && args.length > 0) return isWebUrl(args[0]);
if (cmd === 'newtab') {
const url = args.find(arg => arg !== '--json');
return url === undefined || isWebUrl(url);
}
try {
if (cmd === 'screenshot') {
const shot = parseScreenshotArgs(args);
return shot.base64Mode && shot.outputPath === undefined;
}
if (cmd === 'snapshot') {
const snap = parseSnapshotArgs(args);
return !snap.outputPath && !snap.annotate && !snap.heatmap;
}
} catch {
return false;
}
return true;
}

function isWebUrl(value: unknown): boolean {
try {
const { protocol } = new URL(String(value));
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}

export const commandRoutes: RouteEntry[] = [
Expand Down Expand Up @@ -142,10 +176,10 @@ export const commandRoutes: RouteEntry[] = [
// Tunnel surface: only commands in TUNNEL_COMMANDS are allowed.
// Paired remote agents drive the browser but cannot configure the
// daemon, launch new browsers, import cookies, or rotate tokens.
if (surface === 'tunnel' && !canDispatchOverTunnel(body?.command, body?.args)) {
if (surface === 'tunnel' && !canDispatchOverTunnel(body?.command, body?.args === undefined ? [] : body?.args)) {
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
return jsonError(403, `Command '${body?.command}' is not allowed over the tunnel surface`, {
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Note: --out (disk write) is never allowed over the tunnel.`,
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Over the tunnel, goto/newtab take http(s) URLs only, screenshot needs --base64 and cannot name an output path (target an element with screenshot --base64 --selector <css>), snapshot refuses -a/--annotate, -H/--heatmap and -o, and --out is never allowed.`,
});
}
return ctx.commands.handle(body, tokenInfo);
Expand Down
70 changes: 69 additions & 1 deletion browse/test/dual-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('Tunnel command allowlist', () => {
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
'scroll', 'press', 'type', 'select', 'wait',
// Tab + navigation primitives operator docs and CLI hints already promised
'newtab', 'tabs', 'back', 'forward', 'reload',
// Read/inspect/write operators paired agents need to be useful
Expand Down Expand Up @@ -247,6 +247,74 @@ describe('/command tunnel command allowlist', () => {
expect(resp.body.hint).toContain('Tunnel commands: ');
}
});

const guarded = [
{ command: 'goto', args: ['file://./.env'] },
{ command: 'newtab', args: ['file:///tmp/page.html'] },
{ command: 'eval', args: ['script.js'] },
{ command: 'screenshot', args: ['assets/logo.png'] },
{ command: 'snapshot', args: ['-a', '-o', 'annotated.png'] },
{ command: 'screenshot', args: [] },
{ command: 'screenshot', args: ['--selector', '.hero'] },
{ command: 'snapshot', args: ['-a'] },
{ command: 'snapshot', args: ['-H', '{"@e1":"green"}'] },
];
function recordingCtx(handled: unknown[]) {
return stubRouteContext({
resetIdleTimer: () => {},
commands: {
handle: async (body) => { handled.push(body); return new Response('{"ok":true}'); },
handleInternal: async () => { throw new Error('unexpected handleInternal'); },
},
});
}

test('tunnel surface refuses non-web navigation, eval, and screenshot output paths before dispatch', async () => {
const handled: unknown[] = [];
for (const body of guarded) {
const resp = await statusAndJson(await callRoute('POST', '/command', recordingCtx(handled), { surface: 'tunnel', body }));
expect(resp.status).toBe(403);
expect(resp.body.error).toBe(`Command '${body.command}' is not allowed over the tunnel surface`);
}
expect(handled).toEqual([]);
});

test('local surface dispatches the same commands unchanged', async () => {
const handled: unknown[] = [];
for (const body of guarded) {
const resp = await callRoute('POST', '/command', recordingCtx(handled), { surface: 'local', body });
expect(resp.status).toBe(200);
}
expect(handled).toEqual(guarded);
});

test('a screenshot with no args field is gated like args: [] (tunnel refuses, local dispatches)', async () => {
const handled: unknown[] = [];
const body = { command: 'screenshot' };
const tunnel = await statusAndJson(await callRoute('POST', '/command', recordingCtx(handled), { surface: 'tunnel', body }));
expect(tunnel.status).toBe(403);
expect(tunnel.body.error).toBe(`Command 'screenshot' is not allowed over the tunnel surface`);
expect(handled).toEqual([]);
const local = await callRoute('POST', '/command', recordingCtx(handled), { surface: 'local', body });
expect(local.status).toBe(200);
expect(handled).toEqual([body]);
});

test('tunnel surface still dispatches web navigation and path-free screenshots', async () => {
const handled: unknown[] = [];
const allowed = [
{ command: 'goto', args: ['https://example.com'] },
{ command: 'newtab', args: ['http://localhost:3000/'] },
{ command: 'screenshot', args: ['--base64'] },
{ command: 'screenshot', args: ['--base64', '--selector', '.hero'] },
{ command: 'snapshot', args: ['-i'] },
];
for (const body of allowed) {
const resp = await callRoute('POST', '/command', recordingCtx(handled), { surface: 'tunnel', body });
expect(resp.status).toBe(200);
}
expect(handled).toEqual(allowed);
});
});

describe('Tunnel listener lifecycle', () => {
Expand Down
Loading
Loading