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>
This commit is contained in:
Garry Tan
2026-06-09 21:02:30 -07:00
committed by GitHub
parent 1626d4857b
commit 421460f03a
14 changed files with 553 additions and 27 deletions
+156 -1
View File
@@ -9,7 +9,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { resolveServerScript } from '../src/cli';
import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
@@ -23,6 +23,65 @@ const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
// ─── Pure arg-parser + result-conversion unit tests (no browser) ───
describe('parseOutArgs / hasOutArg', () => {
test('--out <path> splits the flag from the positional', () => {
expect(parseOutArgs(['expr', '--out', '/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
});
test('--out=<path> form is equivalent', () => {
expect(parseOutArgs(['expr', '--out=/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
});
test('flag ordering does not matter', () => {
expect(parseOutArgs(['--out', '/tmp/x', 'expr'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
});
test('--raw and --raw=true|false', () => {
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw']).raw).toBe(true);
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw=true']).raw).toBe(true);
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw=false']).raw).toBe(false);
});
test('repeated --out throws', () => {
expect(() => parseOutArgs(['e', '--out', '/a', '--out', '/b'])).toThrow(/more than once/);
});
test('--out with a missing value throws', () => {
expect(() => parseOutArgs(['e', '--out'])).toThrow(/requires a file path/);
expect(() => parseOutArgs(['e', '--out', '--raw'])).toThrow(/requires a file path/);
expect(() => parseOutArgs(['e', '--out='])).toThrow(/requires a file path/);
});
test('bad --raw value throws', () => {
expect(() => parseOutArgs(['e', '--out', '/a', '--raw=maybe'])).toThrow(/--raw must be true or false/);
});
test('hasOutArg matches --out and --out= exactly, not lookalikes', () => {
expect(hasOutArg(['a', '--out', 'b'])).toBe(true);
expect(hasOutArg(['a', '--out=b'])).toBe(true);
expect(hasOutArg(['a'])).toBe(false);
expect(hasOutArg(['a', '--output', 'b'])).toBe(false);
expect(hasOutArg(['a', '--outx'])).toBe(false);
});
});
describe('resultToString — byte-for-byte with pre-refactor behavior', () => {
test('null becomes "null" (typeof null === object → JSON.stringify)', () => {
expect(resultToString(null)).toBe('null');
});
test('undefined becomes empty string', () => {
expect(resultToString(undefined)).toBe('');
});
test('objects are pretty-printed JSON', () => {
expect(resultToString({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2));
});
test('primitives use String()', () => {
expect(resultToString(42)).toBe('42');
expect(resultToString(true)).toBe('true');
});
});
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
@@ -225,6 +284,102 @@ describe('Inspection', () => {
expect(result).toBe('3');
});
// ─── js/eval --out (render-to-file) ───────────────────────────
test('js (no --out) returns a multi-MB string without truncation', async () => {
// Handler-level guarantee: the result is not sliced/capped before return.
// (Full HTTP egress path is exercised elsewhere; this pins the handler.)
const result = await handleReadCommand('js', ["'x'.repeat(3 * 1024 * 1024)"], bm);
expect(result.length).toBe(3 * 1024 * 1024);
});
test('js --out writes the result to disk and returns a short status, not the payload', async () => {
const out = `/tmp/browse-out-large-${Date.now()}.txt`;
try {
const result = await handleReadCommand('js', ["'y'.repeat(2 * 1024 * 1024)", '--out', out], bm);
expect(result).toContain('JS result written:');
expect(result).toContain(out);
expect(result).toContain(`(${2 * 1024 * 1024} bytes)`);
expect(result.length).toBeLessThan(200); // status, not the 2MB payload
expect(fs.statSync(out).size).toBe(2 * 1024 * 1024);
} finally {
fs.rmSync(out, { force: true });
}
});
test('js --out decodes a base64 PNG data URL to real bytes', async () => {
// 1x1 transparent PNG.
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const out = `/tmp/browse-out-png-${Date.now()}.png`;
try {
const result = await handleReadCommand('js', [`'data:image/png;base64,' + '${b64}'`, '--out', out], bm);
const buf = fs.readFileSync(out);
// PNG magic bytes: 89 50 4E 47
expect([buf[0], buf[1], buf[2], buf[3]]).toEqual([0x89, 0x50, 0x4e, 0x47]);
const expectedLen = Buffer.from(b64, 'base64').length;
expect(buf.length).toBe(expectedLen);
expect(result).toContain(`(${expectedLen} bytes)`);
} finally {
fs.rmSync(out, { force: true });
}
});
test('js --out --raw writes the literal data-URL string (no decode)', async () => {
const dataUrl = 'data:text/plain;base64,aGVsbG8=';
const out = `/tmp/browse-out-raw-${Date.now()}.txt`;
try {
await handleReadCommand('js', [`'${dataUrl}'`, '--out', out, '--raw'], bm);
expect(fs.readFileSync(out, 'utf-8')).toBe(dataUrl);
} finally {
fs.rmSync(out, { force: true });
}
});
test('js --out throws on a malformed base64 data URL instead of writing corrupt bytes', async () => {
const out = `/tmp/browse-out-bad-${Date.now()}.png`;
try {
await expect(
handleReadCommand('js', ["'data:image/png;base64,!!!not-base64!!!'", '--out', out], bm)
).rejects.toThrow(/malformed base64/);
expect(fs.existsSync(out)).toBe(false);
} finally {
fs.rmSync(out, { force: true });
}
});
test('js --out rejects a path outside the safe directories', async () => {
await expect(
handleReadCommand('js', ['1 + 1', '--out', '/etc/browse-should-not-write.txt'], bm)
).rejects.toThrow();
});
test('js --out creates a missing parent directory', async () => {
// validateOutputPath resolves the parent's realpath, so it permits one level
// of missing dir under a safe root (/tmp). mkdir then materializes it.
const root = `/tmp/browse-out-nested-${Date.now()}`;
const out = `${root}/result.txt`;
try {
await handleReadCommand('js', ["'nested'", '--out', out], bm);
expect(fs.readFileSync(out, 'utf-8')).toBe('nested');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('eval --out writes the file result to disk (parity with js)', async () => {
const script = `/tmp/browse-eval-out-src-${Date.now()}.js`;
const out = `/tmp/browse-eval-out-${Date.now()}.txt`;
fs.writeFileSync(script, "'from eval'");
try {
const result = await handleReadCommand('eval', [script, '--out', out], bm);
expect(result).toContain('Eval result written:');
expect(fs.readFileSync(out, 'utf-8')).toBe('from eval');
} finally {
fs.rmSync(script, { force: true });
fs.rmSync(out, { force: true });
}
});
test('css returns computed property', async () => {
const result = await handleReadCommand('css', ['h1', 'color'], bm);
// Navy color
+32
View File
@@ -95,3 +95,35 @@ describe('canDispatchOverTunnel — alias canonicalization', () => {
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);
});
});