From 421460f03ac9f917a3bf02934e5be30ff52d2e25 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 9 Jun 2026 21:02:30 -0700 Subject: [PATCH] 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 / --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) * 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 ] [--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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- BROWSER.md | 4 +- CHANGELOG.md | 67 ++++++++++++ CONTRIBUTING.md | 8 ++ SKILL.md | 4 +- VERSION | 2 +- browse/SKILL.md | 58 +++++++++- browse/SKILL.md.tmpl | 54 +++++++++ browse/src/commands.ts | 4 +- browse/src/read-commands.ts | 137 +++++++++++++++++++++-- browse/src/server.ts | 47 ++++++-- browse/test/commands.test.ts | 157 ++++++++++++++++++++++++++- browse/test/tunnel-gate-unit.test.ts | 32 ++++++ gstack/llms.txt | 4 +- package.json | 2 +- 14 files changed, 553 insertions(+), 27 deletions(-) diff --git a/BROWSER.md b/BROWSER.md index 2c57f1d6e..eb69e8869 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -212,8 +212,8 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table: | Command | Description | |---------|-------------| -| `js ` | Run inline JavaScript expression in page context, return as string | -| `eval ` | Run JS from a file (path under /tmp or cwd; same sandbox as `js`) | +| `js [--out ] [--raw]` | Run inline JavaScript expression in page context, return as string. With `--out ` the result is written to disk instead of returned (a `data:*;base64,...` result is decoded to raw bytes unless `--raw`). `--out` makes the invocation a WRITE (needs `write` scope, never allowed over the tunnel). | +| `eval [--out ] [--raw]` | Run JS from a file (path under /tmp or cwd; same sandbox as `js`). `--out`/`--raw` behave as for `js`. | | `css ` | Computed CSS value | | `attrs ` | Element attributes as JSON | | `is ` | State check: visible, hidden, enabled, disabled, checked, editable, focused | diff --git a/CHANGELOG.md b/CHANGELOG.md index 967255d61..438536cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # Changelog +## [1.57.8.0] - 2026-06-09 + +## **`browse` is now the one Chromium on the box, for offline rendering too.** +## **`js`/`eval --out ` writes a render straight to disk, so skills stop bundling their own puppeteer.** + +You can now turn your own local HTML or JSON into a PNG (or any bytes) on disk +through the same headless `browse` Chromium you already run, with no second +browser install. `js "" --out out.png` and `eval script.js --out out.png` +write the evaluate result to a file instead of returning it. When the result is a +base64 data URL (the shape Excalidraw exports, og-image generators, and card +renderers hand back), `--out` decodes it to raw bytes for you; pass `--raw` to +write the literal string. Malformed base64 errors loudly instead of writing a +corrupt file, and missing parent directories are created. This closes the gap that +made local-render skills each `npm i puppeteer` and download a drifting second +Chromium. + +### The numbers that matter + +No synthetic benchmark — these are structural facts of the change, verifiable from +the diff and a one-line smoke (`browse load-html` → `screenshot --selector` / +`js --out`). + +| For a skill that rasterizes local HTML/JSON | Before | After | +|---|---|---| +| Chromium installs per box | 2+ (browse + each skill's own puppeteer) | 1 (shared `browse`) | +| Getting a PNG from a render function | `evaluate` → multi-MB data URL over the CLI channel → hand-decode base64 → write | `js --out` decodes and writes server-side; only a short status crosses the channel | +| Render-to-file primitive | none | `js`/`eval --out [--raw]` | + +The blessed offline path is documented in the browse skill: visual output goes +through `screenshot --selector` (the picture never crosses the CDP wire), and bytes +a function returns go through `js --out`. + +### What this means for you + +If you write skills that draw diagrams, cards, or og-images, point them at `browse` +and delete the bundled Chromium. One version to pin, one daemon to manage. `--out` +is treated as a write everywhere it matters: it needs the `write` scope, is blocked +over the pair-agent tunnel, and is gated in watch mode, so a remote agent can never +use it to write to your disk. + +### Itemized changes + +#### Added +- **`js` / `eval --out ` render-to-file** (`browse/src/read-commands.ts`). + Writes the evaluate result to disk and returns a short `... result written: + ( bytes)` status. A `data:;base64,...` result is decoded to raw bytes + (case-insensitive header parse, split on the first comma, base64-charset validated + before decode); `--raw` forces a literal write. Parent directories are created. +- **`--raw` flag** to bypass data-URL decoding and write the literal result string. +- **Offline render mode docs** in the browse skill: an explicit headless, no-proxy, + no-Xvfb path with a worked example showing visual (`screenshot --selector`) vs + bytes (`js --out`), a puppeteer→browse cheatsheet row, and a "don't bundle your + own Chromium" note (also in CONTRIBUTING.md). + +#### Changed +- **`--out` is a per-invocation WRITE capability** (`browse/src/server.ts`). + `js`/`eval` stay read commands, but an `--out` invocation requires the `write` + scope, is never dispatchable over the tunnel surface (`canDispatchOverTunnel` now + consults args), and counts as a mutation for watch-mode and tab-ownership gates. + +#### For contributors +- New tests: `parseOutArgs`/`hasOutArg` unit coverage (`--out`/`--out=`, `--raw`, + repeats, missing value, ordering), `--out` render-to-file integration (large + string, data-URL→PNG, `--raw`, malformed-base64, outside-safe-dir, mkdir, eval + parity, byte-for-byte null/undefined), and tunnel-gate guards proving `--out` + is never tunnel-dispatchable. + ## [1.57.7.0] - 2026-06-08 ## **Every plan review now ends by telling you, in one line, whether anything is still unresolved.** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e67a307d1..a4872fc47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -232,6 +232,14 @@ For template authoring best practices (natural language over bash-isms, dynamic To add a browse command, add it to `browse/src/commands.ts`. To add a snapshot flag, add it to `SNAPSHOT_FLAGS` in `browse/src/snapshot.ts`. Then rebuild. +**Don't bundle puppeteer/Chromium in a skill.** `browse` is the one shared +Chromium per box, including offline local-render workloads. A skill that needs to +rasterize its own HTML/JSON (diagrams, cards, og-images) should route through +`browse` — `screenshot --selector` for visual output, `load-html` + `js --out` for +bytes a render function returns — instead of `npm i puppeteer` and downloading a +second Chromium that drifts out of version sync. One install to pin, one daemon to +manage. + ## Jargon list (V1 writing style) gstack's Writing Style section (injected into every tier-≥2 skill's preamble) diff --git a/SKILL.md b/SKILL.md index 0b06b802b..8711ae7f3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -917,10 +917,10 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `cookies` | All cookies as JSON | | `css ` | Computed CSS value | | `dialog [--clear]` | Dialog messages | -| `eval ` | Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. | +| `eval [--out ] [--raw]` | Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. With --out , the result is written to disk (base64 data URL decoded to bytes unless --raw); --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel). | | `inspect [selector] [--all] [--history]` | Deep CSS inspection via CDP — full rule cascade, box model, computed styles | | `is ` | State check on element. Valid values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). accepts a CSS selector OR an @ref token from a prior snapshot (e.g. @e3, @c1) — refs are interchangeable with selectors anywhere a selector is expected. | -| `js ` | Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. | +| `js [--out ] [--raw]` | Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. With --out , the result is written to disk instead of returned (a base64 data URL is decoded to raw bytes unless --raw is given) — ideal for rasterizing local renders to PNG without serializing megabytes back through the CLI. --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel). | | `network [--clear]` | Network requests | | `perf` | Page load timings | | `storage | storage set ` | Read both localStorage and sessionStorage as JSON. With "set ", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). | diff --git a/VERSION b/VERSION index bb68a65d9..caf2638d9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.57.7.0 +1.57.8.0 diff --git a/browse/SKILL.md b/browse/SKILL.md index e36fc9c86..0f670d47a 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -644,6 +644,51 @@ $B screenshot /tmp/out.png --selector .tweet-card ``` Scale must be 1-3 (gstack policy cap). Changing `--scale` recreates the browser context; refs from `snapshot` are invalidated (rerun `snapshot`), but `load-html` content is replayed automatically. Not supported in headed mode. +### 14. Offline render mode (rasterize your own HTML/JSON, zero network) + +This is the blessed path for "I just want to turn my own local HTML or JSON into a +PNG/PDF/bytes on disk" — Excalidraw diagrams, tweet/quote cards, og-images, +report rasterization. It is **plain headless, shared Chromium, no proxy, no Xvfb, +no anti-bot stealth**. Default `$B` is already exactly this; you do not pass +`--headed` or `--proxy`. One Chromium per box, shared by every skill — **do not +`npm i puppeteer` and ship a second browser** (see the note under the cheatsheet). + +Two output shapes, pick by what you have: + +**A) Visual output → `screenshot --selector` (preferred).** If the thing you want +is a picture of something on the page, screenshot it. The PNG is written from the +browser process straight to disk — the image bytes never cross the CDP wire. + +```bash +echo '
hi
' > /tmp/card.html +$B viewport 480x600 --scale 2 +$B load-html /tmp/card.html +$B screenshot /tmp/card.png --selector '#card' # disk path — no megabytes over CDP +``` +(Use the disk path, NOT `screenshot --base64` — base64 serializes the bytes back +through the command channel, which is the cost you're trying to avoid.) + +**B) Bytes a function returns → `js --out` / `eval --out`.** When a library hands +you the result as a return value (a base64 data URL, a blob, computed JSON) rather +than painting a stable element — e.g. Excalidraw's export function returns a PNG +data URL — write the evaluate result straight to disk. `--out` decodes a +`data:*;base64,...` result to raw bytes automatically (pass `--raw` to write the +literal string). The payload is written by the daemon and never serialized back +out to the CLI/stdout. + +```bash +# Load the render bundle, signal readiness, then render-to-file. +$B load-html /tmp/excalidraw-export.html # bundle sets window.__render + a #done flag +$B wait '#done' # deterministic ready handshake +$B js "window.__render(SCENE_JSON)" --out /tmp/diagram.png # data URL → decoded PNG on disk +``` + +`--out` is a WRITE: it needs the `write` scope and is never allowed over the +pair-agent tunnel (a remote agent can't write to your disk). Parent directories +are created; malformed base64 errors instead of writing corrupt bytes. Pick A when +you can (no CDP transfer at all); reach for B only when the bytes come back as a +return value. + ## Puppeteer → browse cheatsheet Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: @@ -657,6 +702,8 @@ Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: | `await (await page.$('.x')).screenshot({path})` | `$B screenshot --selector .x` | | `await page.screenshot({fullPage: true, path})` | `$B screenshot ` (full page default) | | `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot --clip x,y,w,h` | +| `const r = await page.evaluate(fn)` | `$B js ""` (result to stdout) | +| `fs.writeFileSync(out, Buffer.from(dataUrl.split(',')[1],'base64'))` | `$B js "" --out ` (data URL auto-decoded) | Worked example (the tweet-renderer flow — Puppeteer → browse): @@ -671,6 +718,13 @@ $B screenshot /tmp/out.png --selector .tweet-card Aliases: typing `setcontent` or `set-content` routes to `load-html` automatically. Typing a typo (`load-htm`) returns `Did you mean 'load-html'?`. +**Don't bundle your own puppeteer/Chromium.** `browse` is the one shared Chromium +per box. Skills that need to rasterize local HTML/JSON (diagrams, cards, og-images) +should route through `browse` — `screenshot --selector` for visual output, +`load-html` + `js --out` for bytes a function returns — instead of +`npm i puppeteer` and downloading a second Chromium that drifts out of version sync. +One install to pin, one daemon's lifecycle to manage. + ## User Handoff When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor @@ -875,10 +929,10 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | `cookies` | All cookies as JSON | | `css ` | Computed CSS value | | `dialog [--clear]` | Dialog messages | -| `eval ` | Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. | +| `eval [--out ] [--raw]` | Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. With --out , the result is written to disk (base64 data URL decoded to bytes unless --raw); --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel). | | `inspect [selector] [--all] [--history]` | Deep CSS inspection via CDP — full rule cascade, box model, computed styles | | `is ` | State check on element. Valid values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). accepts a CSS selector OR an @ref token from a prior snapshot (e.g. @e3, @c1) — refs are interchangeable with selectors anywhere a selector is expected. | -| `js ` | Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. | +| `js [--out ] [--raw]` | Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. With --out , the result is written to disk instead of returned (a base64 data URL is decoded to raw bytes unless --raw is given) — ideal for rasterizing local renders to PNG without serializing megabytes back through the CLI. --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel). | | `network [--clear]` | Network requests | | `perf` | Page load timings | | `storage | storage set ` | Read both localStorage and sessionStorage as JSON. With "set ", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). | diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index a466fc446..9a159e4c9 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -135,6 +135,51 @@ $B screenshot /tmp/out.png --selector .tweet-card ``` Scale must be 1-3 (gstack policy cap). Changing `--scale` recreates the browser context; refs from `snapshot` are invalidated (rerun `snapshot`), but `load-html` content is replayed automatically. Not supported in headed mode. +### 14. Offline render mode (rasterize your own HTML/JSON, zero network) + +This is the blessed path for "I just want to turn my own local HTML or JSON into a +PNG/PDF/bytes on disk" — Excalidraw diagrams, tweet/quote cards, og-images, +report rasterization. It is **plain headless, shared Chromium, no proxy, no Xvfb, +no anti-bot stealth**. Default `$B` is already exactly this; you do not pass +`--headed` or `--proxy`. One Chromium per box, shared by every skill — **do not +`npm i puppeteer` and ship a second browser** (see the note under the cheatsheet). + +Two output shapes, pick by what you have: + +**A) Visual output → `screenshot --selector` (preferred).** If the thing you want +is a picture of something on the page, screenshot it. The PNG is written from the +browser process straight to disk — the image bytes never cross the CDP wire. + +```bash +echo '
hi
' > /tmp/card.html +$B viewport 480x600 --scale 2 +$B load-html /tmp/card.html +$B screenshot /tmp/card.png --selector '#card' # disk path — no megabytes over CDP +``` +(Use the disk path, NOT `screenshot --base64` — base64 serializes the bytes back +through the command channel, which is the cost you're trying to avoid.) + +**B) Bytes a function returns → `js --out` / `eval --out`.** When a library hands +you the result as a return value (a base64 data URL, a blob, computed JSON) rather +than painting a stable element — e.g. Excalidraw's export function returns a PNG +data URL — write the evaluate result straight to disk. `--out` decodes a +`data:*;base64,...` result to raw bytes automatically (pass `--raw` to write the +literal string). The payload is written by the daemon and never serialized back +out to the CLI/stdout. + +```bash +# Load the render bundle, signal readiness, then render-to-file. +$B load-html /tmp/excalidraw-export.html # bundle sets window.__render + a #done flag +$B wait '#done' # deterministic ready handshake +$B js "window.__render(SCENE_JSON)" --out /tmp/diagram.png # data URL → decoded PNG on disk +``` + +`--out` is a WRITE: it needs the `write` scope and is never allowed over the +pair-agent tunnel (a remote agent can't write to your disk). Parent directories +are created; malformed base64 errors instead of writing corrupt bytes. Pick A when +you can (no CDP transfer at all); reach for B only when the bytes come back as a +return value. + ## Puppeteer → browse cheatsheet Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: @@ -148,6 +193,8 @@ Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: | `await (await page.$('.x')).screenshot({path})` | `$B screenshot --selector .x` | | `await page.screenshot({fullPage: true, path})` | `$B screenshot ` (full page default) | | `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot --clip x,y,w,h` | +| `const r = await page.evaluate(fn)` | `$B js ""` (result to stdout) | +| `fs.writeFileSync(out, Buffer.from(dataUrl.split(',')[1],'base64'))` | `$B js "" --out ` (data URL auto-decoded) | Worked example (the tweet-renderer flow — Puppeteer → browse): @@ -162,6 +209,13 @@ $B screenshot /tmp/out.png --selector .tweet-card Aliases: typing `setcontent` or `set-content` routes to `load-html` automatically. Typing a typo (`load-htm`) returns `Did you mean 'load-html'?`. +**Don't bundle your own puppeteer/Chromium.** `browse` is the one shared Chromium +per box. Skills that need to rasterize local HTML/JSON (diagrams, cards, og-images) +should route through `browse` — `screenshot --selector` for visual output, +`load-html` + `js --out` for bytes a function returns — instead of +`npm i puppeteer` and downloading a second Chromium that drifts out of version sync. +One install to pin, one daemon's lifecycle to manage. + ## User Handoff When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 7e647a002..73bc9ab1b 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -106,8 +106,8 @@ export const COMMAND_DESCRIPTIONS: Record' }, - 'eval': { category: 'Inspection', description: 'Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners.', usage: 'eval ' }, + 'js': { category: 'Inspection', description: 'Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. With --out , the result is written to disk instead of returned (a base64 data URL is decoded to raw bytes unless --raw is given) — ideal for rasterizing local renders to PNG without serializing megabytes back through the CLI. --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel).', usage: 'js [--out ] [--raw]' }, + 'eval': { category: 'Inspection', description: 'Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. With --out , the result is written to disk (base64 data URL decoded to bytes unless --raw); --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel).', usage: 'eval [--out ] [--raw]' }, 'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css ' }, 'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs ' }, 'is': { category: 'Inspection', description: 'State check on element. Valid values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). accepts a CSS selector OR an @ref token from a prior snapshot (e.g. @e3, @c1) — refs are interchangeable with selectors anywhere a selector is expected.', usage: 'is ' }, diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 486eac18a..4e1371a17 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -13,7 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR } from './platform'; import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector'; -import { validateReadPath } from './path-security'; +import { validateReadPath, validateOutputPath } from './path-security'; import { stripLoneSurrogates } from './sanitize'; // Re-export for backward compatibility (tests import from read-commands) export { validateReadPath } from './path-security'; @@ -46,6 +46,117 @@ function wrapForEvaluate(code: string): string { : `(async()=>(${trimmed}))()`; } +/** Flags split out of `js`/`eval` args by parseOutArgs. */ +export interface OutArgs { + outPath?: string; + raw: boolean; + rest: string[]; +} + +/** + * Parse `--out ` / `--out=` and `--raw` / `--raw=true|false` out of an + * arg list, returning the flags plus the remaining positional args (`rest`). + * + * Single source of truth shared by the js/eval handlers and the write-capability + * gate in server.ts, so the two never disagree on what counts as an `--out` + * invocation. Throws on malformed usage (repeated `--out`, missing value, bad + * `--raw` value) so the user gets a clear error instead of a silent misparse. + */ +export function parseOutArgs(args: string[]): OutArgs { + let outPath: string | undefined; + let raw = false; + const rest: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--out') { + if (outPath !== undefined) throw new Error('--out specified more than once'); + const val = args[i + 1]; + if (val === undefined || val.startsWith('--')) throw new Error('--out requires a file path'); + outPath = val; + i++; + } else if (a.startsWith('--out=')) { + if (outPath !== undefined) throw new Error('--out specified more than once'); + const val = a.slice('--out='.length); + if (val === '') throw new Error('--out requires a file path'); + outPath = val; + } else if (a === '--raw') { + raw = true; + } else if (a.startsWith('--raw=')) { + const v = a.slice('--raw='.length).toLowerCase(); + if (v !== 'true' && v !== 'false') throw new Error('--raw must be true or false'); + raw = v === 'true'; + } else { + rest.push(a); + } + } + return { outPath, raw, rest }; +} + +/** + * True iff an arg list contains an `--out` flag in any accepted form + * (`--out ` or `--out=`). Used by the write-capability gate to + * decide whether an otherwise-read command (`js`/`eval`) is actually a write + * invocation. Mirrors parseOutArgs's `--out` recognition exactly. Never throws — + * a malformed `--out=` still counts as an out attempt (fail safe: gate it). + */ +export function hasOutArg(args: string[]): boolean { + return args.some(a => a === '--out' || a.startsWith('--out=')); +} + +/** + * Convert an evaluate() result to its string form — the exact conversion `js`/`eval` + * used inline before `--out` existed. Kept byte-for-byte: `typeof === 'object'` + * (which includes `null`) goes through JSON.stringify (so `null` → `"null"`); + * everything else via `String(result ?? '')` (so `undefined` → `''`). JSON.stringify + * still throws on circular / BigInt-bearing results, same as before. + */ +export function resultToString(result: unknown): string { + return typeof result === 'object' + ? JSON.stringify(result, null, 2) + : String(result ?? ''); +} + +/** + * Write an evaluate result string to disk for `--out`, returning bytes written. + * + * When the result is a base64 data URL (`data:;...;base64,`) and + * `raw` is false, decode the payload to raw bytes — this is the Excalidraw / og-image + * path where a render function returns a PNG data URL. The header is parsed + * case-insensitively and split on the FIRST comma (data URLs can contain commas in + * the payload). The payload is validated against the base64 charset before decoding, + * because `Buffer.from(_, 'base64')` silently drops invalid characters and would + * otherwise write corrupted bytes. `--raw` forces a literal write even for data URLs. + * + * Non-base64 strings are surrogate-sanitized (matching what the stdout egress path + * did before) and written as UTF-8. Parent directories are created — validateOutputPath + * gates the location but does not mkdir. + */ +export function writeEvalResult(outPath: string, str: string, opts: { raw: boolean }): number { + validateOutputPath(outPath); + fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true }); + + if (!opts.raw && str.startsWith('data:')) { + const comma = str.indexOf(','); + if (comma !== -1) { + const header = str.slice('data:'.length, comma); + const tokens = header.split(';').map(t => t.trim().toLowerCase()); + if (tokens.includes('base64')) { + const payload = str.slice(comma + 1).replace(/\s+/g, ''); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) { + throw new Error('--out: malformed base64 in data URL (decode would corrupt output)'); + } + const buf = Buffer.from(payload, 'base64'); + fs.writeFileSync(outPath, buf); + return buf.length; + } + } + } + + const buf = Buffer.from(stripLoneSurrogates(str), 'utf-8'); + fs.writeFileSync(outPath, buf); + return buf.length; +} + /** * Extract clean text from a page (strips script/style/noscript/svg). * Exported for DRY reuse in meta-commands (diff). @@ -179,24 +290,36 @@ export async function handleReadCommand( } case 'js': { - const expr = args[0]; - if (!expr) throw new Error('Usage: browse js '); + const { outPath, raw, rest } = parseOutArgs(args); + const expr = rest[0]; + if (!expr) throw new Error('Usage: browse js [--out ] [--raw]'); if (bm) assertJsOriginAllowed(bm, page.url()); const wrapped = wrapForEvaluate(expr); const result = await target.evaluate(wrapped); - return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); + const str = resultToString(result); + if (outPath) { + const n = writeEvalResult(outPath, str, { raw }); + return `JS result written: ${outPath} (${n} bytes)`; + } + return str; } case 'eval': { - const filePath = args[0]; - if (!filePath) throw new Error('Usage: browse eval '); + const { outPath, raw, rest } = parseOutArgs(args); + const filePath = rest[0]; + if (!filePath) throw new Error('Usage: browse eval [--out ] [--raw]'); if (bm) assertJsOriginAllowed(bm, page.url()); validateReadPath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); const wrapped = wrapForEvaluate(code); const result = await target.evaluate(wrapped); - return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); + const str = resultToString(result); + if (outPath) { + const n = writeEvalResult(outPath, str, { raw }); + return `Eval result written: ${outPath} (${n} bytes)`; + } + return str; } case 'css': { diff --git a/browse/src/server.ts b/browse/src/server.ts index 6f75551ff..301781acc 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -14,7 +14,7 @@ */ import { BrowserManager } from './browser-manager'; -import { handleReadCommand } from './read-commands'; +import { handleReadCommand, hasOutArg } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes'; @@ -330,9 +330,15 @@ export const TUNNEL_COMMANDS = new Set([ * without standing up an HTTP listener. Behavior is identical to the inline * check; the function 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 `) 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. */ -export function canDispatchOverTunnel(command: string | undefined | null): boolean { +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); } @@ -716,6 +722,19 @@ if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) { import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; +/** + * Whether an invocation should be treated as a WRITE for capability gating + * (scope, watch-mode block, tab ownership, tunnel). A command is a write if it + * mutates state (`WRITE_COMMANDS`) OR it carries an `--out` flag — `js`/`eval + * --out` writes the evaluate result to local disk, so the capability is + * per-invocation, not per-command-name. This deliberately does NOT change + * dispatch routing: `js`/`eval` still route to `handleReadCommand`; only the + * security gates consult this. + */ +function isWriteInvocation(command: string, args: string[]): boolean { + return WRITE_COMMANDS.has(command) || hasOutArg(args); +} + // ─── Inspector State (in-memory) ────────────────────────────── let inspectorData: InspectorResult | null = null; let inspectorTimestamp: number = 0; @@ -957,6 +976,19 @@ async function handleCommandInternalImpl( }; } + // `--out` writes the evaluate result to local disk, which is a WRITE + // capability distinct from the JS-exec (admin) capability js/eval need. + // Require write scope so an admin-but-not-write token can't write files. + if (hasOutArg(args) && !tokenInfo.scopes.includes('write')) { + return { + status: 403, json: true, + result: JSON.stringify({ + error: `"--out" writes to disk and requires the "write" scope`, + hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Re-pair with write access to use --out.`, + }), + }; + } + // Domain check for navigation commands if ((command === 'goto' || command === 'newtab') && args[0]) { if (!checkDomain(tokenInfo, args[0])) { @@ -1011,7 +1043,7 @@ async function handleCommandInternalImpl( // Skip for `newtab` — it creates a tab rather than accessing one. if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') { const targetTab = tabId ?? browserManager.getActiveTabId(); - if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: WRITE_COMMANDS.has(command), ownOnly: true })) { + if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: isWriteInvocation(command, args), ownOnly: true })) { return { status: 403, json: true, result: JSON.stringify({ @@ -1035,8 +1067,9 @@ async function handleCommandInternalImpl( }; } - // Block mutation commands while watching (read-only observation mode) - if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) { + // Block mutation commands while watching (read-only observation mode). + // `--out` invocations count as mutations (they write the result to disk). + if (browserManager.isWatching() && isWriteInvocation(command, args)) { return { status: 400, json: true, result: JSON.stringify({ error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.' }), @@ -2650,11 +2683,11 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // Paired remote agents drive the browser but cannot configure the // daemon, launch new browsers, import cookies, or rotate tokens. if (surface === 'tunnel') { - if (!canDispatchOverTunnel(body?.command)) { + if (!canDispatchOverTunnel(body?.command, body?.args)) { logTunnelDenial(req, url, `disallowed_command:${body?.command}`); return new Response(JSON.stringify({ error: `Command '${body?.command}' is not allowed over the tunnel surface`, - hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}`, + hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Note: --out (disk write) is never allowed over the tunnel.`, }), { status: 403, headers: { 'Content-Type': 'application/json' } }); } } diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index b3870c0cc..9382cb27e 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -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 splits the flag from the positional', () => { + expect(parseOutArgs(['expr', '--out', '/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] }); + }); + + test('--out= 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; 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 diff --git a/browse/test/tunnel-gate-unit.test.ts b/browse/test/tunnel-gate-unit.test.ts index f6d61c13a..6fcdd9e51 100644 --- a/browse/test/tunnel-gate-unit.test.ts +++ b/browse/test/tunnel-gate-unit.test.ts @@ -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); + }); +}); diff --git a/gstack/llms.txt b/gstack/llms.txt index a11b045d1..9f9f717ec 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -81,10 +81,10 @@ Run with `browse [args]`. Full reference: `browse/SKILL.md`. - `cookies`: All cookies as JSON - `css `: Computed CSS value - `dialog [--clear]`: Dialog messages -- `eval `: Run JavaScript from a file in the page context and return result as string. +- `eval [--out ] [--raw]`: Run JavaScript from a file in the page context and return result as string. - `inspect [selector] [--all] [--history]`: Deep CSS inspection via CDP — full rule cascade, box model, computed styles - `is `: State check on element. -- `js `: Run inline JavaScript expression in the page context and return result as string. +- `js [--out ] [--raw]`: Run inline JavaScript expression in the page context and return result as string. - `network [--clear]`: Network requests - `perf`: Page load timings - `storage | storage set `: Read both localStorage and sessionStorage as JSON. diff --git a/package.json b/package.json index 229d7034c..789aa8db8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.57.7.0", + "version": "1.57.8.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",