mirror of https://github.com/garrytan/gstack.git
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:
parent
1626d4857b
commit
421460f03a
|
|
@ -212,8 +212,8 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `js <expr>` | Run inline JavaScript expression in page context, return as string |
|
| `js <expr> [--out <file>] [--raw]` | Run inline JavaScript expression in page context, return as string. With `--out <file>` 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 <file>` | Run JS from a file (path under /tmp or cwd; same sandbox as `js`) |
|
| `eval <file> [--out <file>] [--raw]` | Run JS from a file (path under /tmp or cwd; same sandbox as `js`). `--out`/`--raw` behave as for `js`. |
|
||||||
| `css <sel> <prop>` | Computed CSS value |
|
| `css <sel> <prop>` | Computed CSS value |
|
||||||
| `attrs <sel\|@ref>` | Element attributes as JSON |
|
| `attrs <sel\|@ref>` | Element attributes as JSON |
|
||||||
| `is <prop> <sel\|@ref>` | State check: visible, hidden, enabled, disabled, checked, editable, focused |
|
| `is <prop> <sel\|@ref>` | State check: visible, hidden, enabled, disabled, checked, editable, focused |
|
||||||
|
|
|
||||||
67
CHANGELOG.md
67
CHANGELOG.md
|
|
@ -1,5 +1,72 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.57.8.0] - 2026-06-09
|
||||||
|
|
||||||
|
## **`browse` is now the one Chromium on the box, for offline rendering too.**
|
||||||
|
## **`js`/`eval --out <file>` 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 "<expr>" --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 <file>` render-to-file** (`browse/src/read-commands.ts`).
|
||||||
|
Writes the evaluate result to disk and returns a short `... result written: <path>
|
||||||
|
(<N> bytes)` status. A `data:<type>;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
|
## [1.57.7.0] - 2026-06-08
|
||||||
|
|
||||||
## **Every plan review now ends by telling you, in one line, whether anything is still unresolved.**
|
## **Every plan review now ends by telling you, in one line, whether anything is still unresolved.**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
## Jargon list (V1 writing style)
|
||||||
|
|
||||||
gstack's Writing Style section (injected into every tier-≥2 skill's preamble)
|
gstack's Writing Style section (injected into every tier-≥2 skill's preamble)
|
||||||
|
|
|
||||||
4
SKILL.md
4
SKILL.md
|
|
@ -917,10 +917,10 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||||
| `cookies` | All cookies as JSON |
|
| `cookies` | All cookies as JSON |
|
||||||
| `css <sel> <prop>` | Computed CSS value |
|
| `css <sel> <prop>` | Computed CSS value |
|
||||||
| `dialog [--clear]` | Dialog messages |
|
| `dialog [--clear]` | Dialog messages |
|
||||||
| `eval <file>` | 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 <file> [--out <file>] [--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 <file>, 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 |
|
| `inspect [selector] [--all] [--history]` | Deep CSS inspection via CDP — full rule cascade, box model, computed styles |
|
||||||
| `is <prop> <sel|@ref>` | State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> 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. |
|
| `is <prop> <sel|@ref>` | State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> 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 <expr>` | 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 <expr> [--out <file>] [--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 <file>, 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 |
|
| `network [--clear]` | Network requests |
|
||||||
| `perf` | Page load timings |
|
| `perf` | Page load timings |
|
||||||
| `storage | storage set <key> <value>` | Read both localStorage and sessionStorage as JSON. With "set <key> <value>", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). |
|
| `storage | storage set <key> <value>` | Read both localStorage and sessionStorage as JSON. With "set <key> <value>", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 '<div id="card" style="width:400px;height:200px;background:#1da1f2;color:#fff;padding:20px">hi</div>' > /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
|
## Puppeteer → browse cheatsheet
|
||||||
|
|
||||||
Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow:
|
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 <path> --selector .x` |
|
| `await (await page.$('.x')).screenshot({path})` | `$B screenshot <path> --selector .x` |
|
||||||
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
||||||
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
||||||
|
| `const r = await page.evaluate(fn)` | `$B js "<expr>"` (result to stdout) |
|
||||||
|
| `fs.writeFileSync(out, Buffer.from(dataUrl.split(',')[1],'base64'))` | `$B js "<expr>" --out <file>` (data URL auto-decoded) |
|
||||||
|
|
||||||
Worked example (the tweet-renderer flow — Puppeteer → browse):
|
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'?`.
|
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
|
## User Handoff
|
||||||
|
|
||||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
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 |
|
| `cookies` | All cookies as JSON |
|
||||||
| `css <sel> <prop>` | Computed CSS value |
|
| `css <sel> <prop>` | Computed CSS value |
|
||||||
| `dialog [--clear]` | Dialog messages |
|
| `dialog [--clear]` | Dialog messages |
|
||||||
| `eval <file>` | 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 <file> [--out <file>] [--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 <file>, 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 |
|
| `inspect [selector] [--all] [--history]` | Deep CSS inspection via CDP — full rule cascade, box model, computed styles |
|
||||||
| `is <prop> <sel|@ref>` | State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> 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. |
|
| `is <prop> <sel|@ref>` | State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> 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 <expr>` | 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 <expr> [--out <file>] [--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 <file>, 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 |
|
| `network [--clear]` | Network requests |
|
||||||
| `perf` | Page load timings |
|
| `perf` | Page load timings |
|
||||||
| `storage | storage set <key> <value>` | Read both localStorage and sessionStorage as JSON. With "set <key> <value>", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). |
|
| `storage | storage set <key> <value>` | Read both localStorage and sessionStorage as JSON. With "set <key> <value>", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 '<div id="card" style="width:400px;height:200px;background:#1da1f2;color:#fff;padding:20px">hi</div>' > /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
|
## Puppeteer → browse cheatsheet
|
||||||
|
|
||||||
Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow:
|
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 <path> --selector .x` |
|
| `await (await page.$('.x')).screenshot({path})` | `$B screenshot <path> --selector .x` |
|
||||||
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
||||||
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
||||||
|
| `const r = await page.evaluate(fn)` | `$B js "<expr>"` (result to stdout) |
|
||||||
|
| `fs.writeFileSync(out, Buffer.from(dataUrl.split(',')[1],'base64'))` | `$B js "<expr>" --out <file>` (data URL auto-decoded) |
|
||||||
|
|
||||||
Worked example (the tweet-renderer flow — Puppeteer → browse):
|
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'?`.
|
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
|
## User Handoff
|
||||||
|
|
||||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||||
'media': { category: 'Reading', description: 'All media elements (images, videos, audio) with URLs, dimensions, types', usage: 'media [--images|--videos|--audio] [selector]' },
|
'media': { category: 'Reading', description: 'All media elements (images, videos, audio) with URLs, dimensions, types', usage: 'media [--images|--videos|--audio] [selector]' },
|
||||||
'data': { category: 'Reading', description: 'Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags', usage: 'data [--jsonld|--og|--meta|--twitter]' },
|
'data': { category: 'Reading', description: 'Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags', usage: 'data [--jsonld|--og|--meta|--twitter]' },
|
||||||
// Inspection
|
// Inspection
|
||||||
'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.', usage: 'js <expr>' },
|
'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 <file>, 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 <expr> [--out <file>] [--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.', usage: 'eval <file>' },
|
'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 <file>, 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 <file> [--out <file>] [--raw]' },
|
||||||
'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css <sel> <prop>' },
|
'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css <sel> <prop>' },
|
||||||
'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs <sel|@ref>' },
|
'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs <sel|@ref>' },
|
||||||
'is': { category: 'Inspection', description: 'State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> 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 <prop> <sel|@ref>' },
|
'is': { category: 'Inspection', description: 'State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> 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 <prop> <sel|@ref>' },
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { TEMP_DIR } from './platform';
|
import { TEMP_DIR } from './platform';
|
||||||
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
|
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
|
||||||
import { validateReadPath } from './path-security';
|
import { validateReadPath, validateOutputPath } from './path-security';
|
||||||
import { stripLoneSurrogates } from './sanitize';
|
import { stripLoneSurrogates } from './sanitize';
|
||||||
// Re-export for backward compatibility (tests import from read-commands)
|
// Re-export for backward compatibility (tests import from read-commands)
|
||||||
export { validateReadPath } from './path-security';
|
export { validateReadPath } from './path-security';
|
||||||
|
|
@ -46,6 +46,117 @@ function wrapForEvaluate(code: string): string {
|
||||||
: `(async()=>(${trimmed}))()`;
|
: `(async()=>(${trimmed}))()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flags split out of `js`/`eval` args by parseOutArgs. */
|
||||||
|
export interface OutArgs {
|
||||||
|
outPath?: string;
|
||||||
|
raw: boolean;
|
||||||
|
rest: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `--out <path>` / `--out=<path>` 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 <path>` or `--out=<path>`). 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:<type>;...;base64,<payload>`) 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).
|
* Extract clean text from a page (strips script/style/noscript/svg).
|
||||||
* Exported for DRY reuse in meta-commands (diff).
|
* Exported for DRY reuse in meta-commands (diff).
|
||||||
|
|
@ -179,24 +290,36 @@ export async function handleReadCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'js': {
|
case 'js': {
|
||||||
const expr = args[0];
|
const { outPath, raw, rest } = parseOutArgs(args);
|
||||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
const expr = rest[0];
|
||||||
|
if (!expr) throw new Error('Usage: browse js <expression> [--out <file>] [--raw]');
|
||||||
if (bm) assertJsOriginAllowed(bm, page.url());
|
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||||
const wrapped = wrapForEvaluate(expr);
|
const wrapped = wrapForEvaluate(expr);
|
||||||
const result = await target.evaluate(wrapped);
|
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': {
|
case 'eval': {
|
||||||
const filePath = args[0];
|
const { outPath, raw, rest } = parseOutArgs(args);
|
||||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
const filePath = rest[0];
|
||||||
|
if (!filePath) throw new Error('Usage: browse eval <js-file> [--out <file>] [--raw]');
|
||||||
if (bm) assertJsOriginAllowed(bm, page.url());
|
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||||
validateReadPath(filePath);
|
validateReadPath(filePath);
|
||||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||||
const code = fs.readFileSync(filePath, 'utf-8');
|
const code = fs.readFileSync(filePath, 'utf-8');
|
||||||
const wrapped = wrapForEvaluate(code);
|
const wrapped = wrapForEvaluate(code);
|
||||||
const result = await target.evaluate(wrapped);
|
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': {
|
case 'css': {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserManager } from './browser-manager';
|
import { BrowserManager } from './browser-manager';
|
||||||
import { handleReadCommand } from './read-commands';
|
import { handleReadCommand, hasOutArg } from './read-commands';
|
||||||
import { handleWriteCommand } from './write-commands';
|
import { handleWriteCommand } from './write-commands';
|
||||||
import { handleMetaCommand } from './meta-commands';
|
import { handleMetaCommand } from './meta-commands';
|
||||||
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
||||||
|
|
@ -330,9 +330,15 @@ export const TUNNEL_COMMANDS = new Set<string>([
|
||||||
* without standing up an HTTP listener. Behavior is identical to the inline
|
* without standing up an HTTP listener. Behavior is identical to the inline
|
||||||
* check; the function canonicalizes the command (so aliases hit the same set)
|
* check; the function canonicalizes the command (so aliases hit the same set)
|
||||||
* and returns false for null/undefined input.
|
* 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.
|
||||||
*/
|
*/
|
||||||
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 (typeof command !== 'string' || command.length === 0) return false;
|
||||||
|
if (Array.isArray(args) && hasOutArg(args)) return false;
|
||||||
const cmd = canonicalizeCommand(command);
|
const cmd = canonicalizeCommand(command);
|
||||||
return TUNNEL_COMMANDS.has(cmd);
|
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';
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||||
export { READ_COMMANDS, WRITE_COMMANDS, META_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) ──────────────────────────────
|
// ─── Inspector State (in-memory) ──────────────────────────────
|
||||||
let inspectorData: InspectorResult | null = null;
|
let inspectorData: InspectorResult | null = null;
|
||||||
let inspectorTimestamp: number = 0;
|
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
|
// Domain check for navigation commands
|
||||||
if ((command === 'goto' || command === 'newtab') && args[0]) {
|
if ((command === 'goto' || command === 'newtab') && args[0]) {
|
||||||
if (!checkDomain(tokenInfo, 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.
|
// Skip for `newtab` — it creates a tab rather than accessing one.
|
||||||
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') {
|
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') {
|
||||||
const targetTab = tabId ?? browserManager.getActiveTabId();
|
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 {
|
return {
|
||||||
status: 403, json: true,
|
status: 403, json: true,
|
||||||
result: JSON.stringify({
|
result: JSON.stringify({
|
||||||
|
|
@ -1035,8 +1067,9 @@ async function handleCommandInternalImpl(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block mutation commands while watching (read-only observation mode)
|
// Block mutation commands while watching (read-only observation mode).
|
||||||
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
// `--out` invocations count as mutations (they write the result to disk).
|
||||||
|
if (browserManager.isWatching() && isWriteInvocation(command, args)) {
|
||||||
return {
|
return {
|
||||||
status: 400, json: true,
|
status: 400, json: true,
|
||||||
result: JSON.stringify({ error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.' }),
|
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
|
// Paired remote agents drive the browser but cannot configure the
|
||||||
// daemon, launch new browsers, import cookies, or rotate tokens.
|
// daemon, launch new browsers, import cookies, or rotate tokens.
|
||||||
if (surface === 'tunnel') {
|
if (surface === 'tunnel') {
|
||||||
if (!canDispatchOverTunnel(body?.command)) {
|
if (!canDispatchOverTunnel(body?.command, body?.args)) {
|
||||||
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
|
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: `Command '${body?.command}' is not allowed over the tunnel surface`,
|
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' } });
|
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { startTestServer } from './test-server';
|
import { startTestServer } from './test-server';
|
||||||
import { BrowserManager } from '../src/browser-manager';
|
import { BrowserManager } from '../src/browser-manager';
|
||||||
import { resolveServerScript } from '../src/cli';
|
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 { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
||||||
import { handleMetaCommand } from '../src/meta-commands';
|
import { handleMetaCommand } from '../src/meta-commands';
|
||||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
|
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) =>
|
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
_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 testServer: ReturnType<typeof startTestServer>;
|
||||||
let bm: BrowserManager;
|
let bm: BrowserManager;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
|
|
@ -225,6 +284,102 @@ describe('Inspection', () => {
|
||||||
expect(result).toBe('3');
|
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 () => {
|
test('css returns computed property', async () => {
|
||||||
const result = await handleReadCommand('css', ['h1', 'color'], bm);
|
const result = await handleReadCommand('css', ['h1', 'color'], bm);
|
||||||
// Navy color
|
// Navy color
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,35 @@ describe('canDispatchOverTunnel — alias canonicalization', () => {
|
||||||
expect(canDispatchOverTunnel('closetab')).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,10 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
|
||||||
- `cookies`: All cookies as JSON
|
- `cookies`: All cookies as JSON
|
||||||
- `css <sel> <prop>`: Computed CSS value
|
- `css <sel> <prop>`: Computed CSS value
|
||||||
- `dialog [--clear]`: Dialog messages
|
- `dialog [--clear]`: Dialog messages
|
||||||
- `eval <file>`: Run JavaScript from a file in the page context and return result as string.
|
- `eval <file> [--out <file>] [--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
|
- `inspect [selector] [--all] [--history]`: Deep CSS inspection via CDP — full rule cascade, box model, computed styles
|
||||||
- `is <prop> <sel|@ref>`: State check on element.
|
- `is <prop> <sel|@ref>`: State check on element.
|
||||||
- `js <expr>`: Run inline JavaScript expression in the page context and return result as string.
|
- `js <expr> [--out <file>] [--raw]`: Run inline JavaScript expression in the page context and return result as string.
|
||||||
- `network [--clear]`: Network requests
|
- `network [--clear]`: Network requests
|
||||||
- `perf`: Page load timings
|
- `perf`: Page load timings
|
||||||
- `storage | storage set <key> <value>`: Read both localStorage and sessionStorage as JSON.
|
- `storage | storage set <key> <value>`: Read both localStorage and sessionStorage as JSON.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "gstack",
|
"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.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue