Files
gstack/browse/test/tunnel-gate-unit.test.ts
T
Garry Tan 421460f03a v1.57.8.0 feat: browse js/eval --out render-to-file (canonical Chromium for offline rendering) (#1929)
* feat(browse): js/eval --out render-to-file with write-capability gate

Add --out <file> / --raw to js and eval so an evaluate result is written
straight to disk (base64 data URLs auto-decoded to bytes, charset-validated
before decode, parent dirs created) instead of serialized back through the
CLI. --out is modeled as a per-invocation WRITE: it requires write scope, is
never dispatchable over the pair-agent tunnel (canDispatchOverTunnel now
consults args), and counts as a mutation for watch-mode and tab-ownership.
Shared parseOutArgs/hasOutArg/resultToString helpers keep the handler and the
gate in sync. Tests cover the parser, render-to-file paths, and tunnel guards.

* docs(browse): offline render mode + canonical-Chromium guidance

Document the blessed offline-render path (headless, no proxy/Xvfb): visual
output via screenshot --selector, bytes a function returns via js --out.
Add the puppeteer->browse cheatsheet row, a "don't bundle your own Chromium"
note (browse skill + CONTRIBUTING), and the --out/--raw command descriptions.
Regenerate browse/SKILL.md, SKILL.md, and gstack/llms.txt from the templates.

* chore: bump version and changelog (v1.59.1.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document js/eval --out render-to-file in BROWSER.md reference (v1.59.1.0)

The js and eval reference rows in BROWSER.md drifted: every other reference
surface (SKILL.md, gstack/llms.txt, browse/SKILL.md) already shows the new
[--out <file>] [--raw] flags from v1.59.1.0, but the complete browser
reference still showed the pre-feature signatures. Add the flags plus the
WRITE-capability / no-tunnel note so the reference matches what shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: re-version 1.59.1.0 -> 1.57.8.0 (natural PATCH from 1.57.7.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:02:30 -07:00

130 lines
5.5 KiB
TypeScript

/**
* Unit-test the pure tunnel-gate function extracted from the /command handler.
*
* The gate decides whether a paired remote agent's request to `/command` over
* the tunnel surface is allowed (returns true) or 403'd (returns false). Pure,
* synchronous, no HTTP — testable without standing up a Bun.serve listener.
*
* The behavioral coverage of the gate firing on the right surface (and only
* the right surface) lives in `pair-agent-tunnel-eval.test.ts` (paid eval,
* gate-tier).
*/
import { describe, test, expect } from 'bun:test';
import { canDispatchOverTunnel, TUNNEL_COMMANDS } from '../src/server';
describe('canDispatchOverTunnel — closed allowlist', () => {
test('every command in TUNNEL_COMMANDS dispatches over tunnel', () => {
for (const cmd of TUNNEL_COMMANDS) {
expect(canDispatchOverTunnel(cmd)).toBe(true);
}
});
test('TUNNEL_COMMANDS contains the 26-command closed set', () => {
// Mirror the source-level guard in dual-listener.test.ts. If this ever
// disagrees with the literal in server.ts, one of them is wrong.
const expected = new Set([
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
'newtab', 'tabs', 'back', 'forward', 'reload',
'snapshot', 'fill', 'url', 'closetab',
]);
expect(TUNNEL_COMMANDS.size).toBe(expected.size);
for (const c of expected) expect(TUNNEL_COMMANDS.has(c)).toBe(true);
for (const c of TUNNEL_COMMANDS) expect(expected.has(c)).toBe(true);
});
});
describe('canDispatchOverTunnel — daemon-config + bootstrap commands rejected', () => {
const blocked = [
'pair', 'unpair', 'cookies', 'setup',
'launch', 'launch-browser', 'connect', 'disconnect',
'restart', 'stop', 'tunnel-start', 'tunnel-stop',
'token-mint', 'token-revoke', 'cookie-picker', 'cookie-import',
'inspector-pick', 'extension-inspect',
'invalid-command-xyz', 'totally-made-up',
];
for (const cmd of blocked) {
test(`rejects '${cmd}'`, () => {
expect(canDispatchOverTunnel(cmd)).toBe(false);
});
}
});
describe('canDispatchOverTunnel — null/undefined/empty input', () => {
test('returns false for empty string', () => {
expect(canDispatchOverTunnel('')).toBe(false);
});
test('returns false for undefined', () => {
expect(canDispatchOverTunnel(undefined)).toBe(false);
});
test('returns false for null', () => {
expect(canDispatchOverTunnel(null)).toBe(false);
});
test('returns false for non-string input (defensive)', () => {
// The body parser may hand the gate a number or object if a malicious
// client sends `{"command": 42}`. The pure gate must treat anything
// non-string as not-allowed rather than throw.
expect(canDispatchOverTunnel(42 as unknown as string)).toBe(false);
expect(canDispatchOverTunnel({} as unknown as string)).toBe(false);
});
});
describe('canDispatchOverTunnel — alias canonicalization', () => {
// canonicalizeCommand resolves aliases (e.g. 'set-content' → 'load-html').
// Any aliased form of an allowlisted canonical command should also pass the
// gate; aliases that resolve to a non-allowlisted canonical command should
// not. We don't hardcode alias names here — we read from the source registry
// by importing what we need from commands.ts.
test('aliases that resolve to allowlisted commands pass the gate', () => {
// 'set-content' canonicalizes to 'load-html'. 'load-html' is NOT in
// TUNNEL_COMMANDS, so 'set-content' must also be rejected. This guards
// against a future alias that accidentally maps a tunnel-allowed name to
// a non-tunnel-allowed canonical (e.g. 'goto' → 'navigate' would break).
expect(canDispatchOverTunnel('set-content')).toBe(false);
});
test('canonical commands pass directly without alias lookup', () => {
expect(canDispatchOverTunnel('goto')).toBe(true);
expect(canDispatchOverTunnel('newtab')).toBe(true);
expect(canDispatchOverTunnel('closetab')).toBe(true);
});
});
describe('canDispatchOverTunnel — --out writes are never tunnel-dispatchable', () => {
// `--out` turns an otherwise-readable command into a local-disk WRITE. The
// tunnel surface never grants disk-write to remote paired agents, so any
// --out invocation must be 403'd even when the bare command is allowlisted.
test('bare eval dispatches, but eval --out does not', () => {
expect(canDispatchOverTunnel('eval', ['/tmp/x.js'])).toBe(true);
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--out', '/tmp/o.png'])).toBe(false);
});
test('--out= form is rejected too (no parser-shape bypass)', () => {
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--out=/tmp/o.png'])).toBe(false);
});
test('--out anywhere in args is caught regardless of ordering', () => {
expect(canDispatchOverTunnel('eval', ['--out', '/tmp/o.png', '/tmp/x.js'])).toBe(false);
});
test('args without --out still dispatch', () => {
expect(canDispatchOverTunnel('goto', ['https://example.com'])).toBe(true);
expect(canDispatchOverTunnel('eval', ['/tmp/x.js'])).toBe(true);
});
test('omitting args preserves the old command-only behavior', () => {
expect(canDispatchOverTunnel('eval')).toBe(true);
});
test('a lookalike flag (--output) is NOT treated as --out', () => {
// hasOutArg matches '--out' exactly or '--out='; '--output' must not trip it.
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--output', '/tmp/o'])).toBe(true);
});
});