From c15b805cd864e99545d34a573fe1a16a6c0919bb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 18 Apr 2026 23:25:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(browse):=20Puppeteer=20parity=20=E2=80=94?= =?UTF-8?q?=20load-html,=20screenshot=20--selector,=20viewport=20--scale,?= =?UTF-8?q?=20file://=20(v1.1.0.0)=20(#1062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(browse): TabSession loadedHtml + command aliases + DX polish primitives Adds the foundation layer for Puppeteer-parity features: - TabSession.loadedHtml + setTabContent/getLoadedHtml/clearLoadedHtml — enables load-html content to survive context recreation (viewport --scale) via in-memory replay. ASCII lifecycle diagram in the source explains the clear-before-navigation contract. - COMMAND_ALIASES + canonicalizeCommand() helper — single source of truth for name aliases (setcontent / set-content / setContent → load-html), consumed by server dispatch and chain prevalidation. - buildUnknownCommandError() pure function — rich error messages with Levenshtein-based "Did you mean" suggestions (distance ≤ 2, input length ≥ 4 to skip 2-letter noise) and NEW_IN_VERSION upgrade hints. - load-html registered in WRITE_COMMANDS + SCOPE_WRITE so scoped write tokens can use it. - screenshot and viewport descriptions updated for upcoming flags. - New browse/test/dx-polish.test.ts (15 tests): alias canonicalization, Levenshtein threshold + alphabetical tiebreak, short-input guard, NEW_IN_VERSION upgrade hint, alias + scope integration invariants. No consumers yet — pure additive foundation. Safe to bisect on its own. * feat(browse): accept file:// in goto with smart cwd/home-relative parsing Extends validateNavigationUrl to accept file:// URLs scoped to safe dirs (cwd + TEMP_DIR) via the existing validateReadPath policy. The workhorse is a new normalizeFileUrl() helper that handles non-standard relative forms BEFORE the WHATWG URL parser sees them: file:///abs/path.html → unchanged file://./docs/page.html → file:///docs/page.html file://~/Documents/page.html → file:///Documents/page.html file://docs/page.html → file:///docs/page.html file://localhost/abs/path → unchanged file://host.example.com/... → rejected (UNC/network) file:// and file:/// → rejected (would list a directory) Host heuristic rejects segments with '.', ':', '\\', '%', IPv6 brackets, or Windows drive-letter patterns — so file://docs.v1/page.html, file://127.0.0.1/x, file://[::1]/x, and file://C:/Users/x are explicit errors. Uses fileURLToPath() + pathToFileURL() from node:url (never string-concat) so URL escapes like %20 decode correctly and Node rejects encoded-slash traversal (%2F..%2F) outright. Signature change: validateNavigationUrl now returns Promise (the normalized URL) instead of Promise. Existing callers that ignore the return value still compile — they just don't benefit from smart-parsing until updated in follow-up commits. Callers will be migrated in the next few commits (goto, diff, newTab, restoreState). Rewrites the url-validation test file: updates existing tests for the new return type, adds 20+ new tests covering every normalizeFileUrl shape variant, URL-encoding edge cases, and path-traversal rejection. References: codex consult v3 P1 findings on URL parser semantics and fileURLToPath. * feat(browse): BrowserManager deviceScaleFactor + setContent replay + file:// plumbing Three tightly-coupled changes to BrowserManager, all in service of the Puppeteer-parity workflow: 1. deviceScaleFactor + currentViewport tracking. New private fields (default scale=1, viewport=1280x720) + setDeviceScaleFactor(scale, w, h) method. deviceScaleFactor is a context-level Playwright option — changing it requires recreateContext(). The method validates (finite number, 1-3 cap, headed-mode rejected), stores new values, calls recreateContext(), and rolls back the fields on failure so a bad call doesn't leave inconsistent state. Context options at all three sites (launch, recreate happy path, recreate fallback) now honor the stored values instead of hardcoding 1280x720. 2. BrowserState.loadedHtml + loadedHtmlWaitUntil. saveState captures per-tab loadedHtml from the session; restoreState replays it via newSession. setTabContent() — NOT bare page.setContent() — so TabSession.loadedHtml is rehydrated and survives *subsequent* scale changes. In-memory only, never persisted to disk (HTML may contain secrets or customer data). 3. newTab + restoreState now consume validateNavigationUrl's normalized return value. file://./x, file://~/x, and bare-segment forms now take effect at every navigation site, not just the top-level goto command. Together these enable: load-html → viewport --scale 2 → viewport --scale 1.5 → screenshot, with content surviving both context recreations. Codex v2 P0 flagged that bare page.setContent in restoreState would lose content on the second scale change — this commit implements the rehydration path. References: codex v2 P0 (TabSession rehydration), codex v3 P1 (4-caller return value), plan Feature 3 + Feature 4. * feat(browse): load-html, screenshot --selector, viewport --scale, alias dispatch Wires the new handlers and dispatch logic that the previous commits made possible: write-commands.ts - New 'load-html' case: validateReadPath for safe-dir scoping, stat-based actionable errors (not found, directory, oversize), extension allowlist (.html/.htm/.xhtml/.svg), magic-byte sniff with UTF-8 BOM strip accepting any <[a-zA-Z!?] markup opener (not just ... work for setContent), 50MB cap via GSTACK_BROWSE_MAX_HTML_BYTES override, frame-context rejection. Calls session.setTabContent() so replay metadata is rehydrated. - viewport command extended: optional [], optional [--scale ], scale-only variant reads current size via page.viewportSize(). Invalid scale (NaN, Infinity, empty, out of 1-3) throws with named value. Headed mode rejected explicitly. - clearLoadedHtml() called BEFORE goto/back/forward/reload navigation (not after) so a timed-out goto post-commit doesn't leave stale metadata that could resurrect on a later context recreation. Codex v2 P1 catch. - goto uses validateNavigationUrl's normalized return value. meta-commands.ts - screenshot --selector flag: explicit element-screenshot form. Rejects alongside positional selector (both = error), preserves --clip conflict at line 161, composes with --base64 at lines 168-174. - chain canonicalizes each step with canonicalizeCommand — step shape is now { rawName, name, args } so prevalidation, dispatch, WRITE_COMMANDS.has, watch blocking, and result labels all use canonical names while audit labels show 'rawName→name' when aliased. Codex v3 P2 catch — prior shape only canonicalized at prevalidation and diverged everywhere else. - diff command consumes validateNavigationUrl return value for both URLs. server.ts - Command canonicalization inserted immediately after parse, before scope / watch / tab-ownership / content-wrapping checks. rawCommand preserved for future audit (not wired into audit log in this commit — follow-up). - Unknown-command handler replaced with buildUnknownCommandError() from commands.ts — produces 'Unknown command: X. Did you mean Y?' with optional upgrade hint for NEW_IN_VERSION entries. security-audit-r2.test.ts - Updated chain-loop marker from 'for (const cmd of commands)' to 'for (const c of commands)' to match the new chain step shape. Same isWatching + BLOCKED invariants still asserted. * chore: bump version and changelog (v1.1.0.0) - VERSION: 1.0.0.0 → 1.1.0.0 (MINOR bump — new user-facing commands) - package.json: matching version bump - CHANGELOG.md: new 1.1.0.0 entry describing load-html, screenshot --selector, viewport --scale, file:// support, setContent replay, and DX polish in user voice with a dedicated Security section for file:// safe-dirs policy - browse/SKILL.md.tmpl: adds pattern #12 "Render local HTML", pattern #13 "Retina screenshots", and a full Puppeteer → browse cheatsheet with side-by- side API mapping and a worked tweet-renderer migration example - browse/SKILL.md + SKILL.md: regenerated from templates via `bun run gen:skill-docs` to reflect the new command descriptions Co-Authored-By: Claude Opus 4.7 (1M context) * fix: pre-landing review fixes (9 findings from specialist + adversarial review) Adversarial review (Claude subagent + Codex) surfaced 9 bugs across CRITICAL/HIGH severity. All fixed: 1. tab-session.ts:setTabContent — state mutation moved AFTER the setContent await. Prior order left phantom HTML in replay metadata if setContent threw (timeout, browser crash), which a later viewport --scale would silently replay. Now loadedHtml is only recorded on successful load. 2. browser-manager.ts:setDeviceScaleFactor — rollback now forces a second recreateContext after restoring the old fields. The fallback path in the original recreateContext builds a blank context using whatever this.deviceScaleFactor/currentViewport hold at that moment (which were the NEW values we were trying to apply). Rolling back the fields without a second recreate left the live context at new-scale while state tracked old-scale. Now: restore fields, force re-recreate with old values, only if that ALSO fails do we return a combined error. 3. commands.ts:buildUnknownCommandError — Levenshtein tiebreak simplified to 'd <= 2 && d < bestDist' (strict less). Candidates are pre-sorted alphabetically, so first equal-distance wins by default. The prior '(d === bestDist && best !== undefined && cand < best)' clause was dead code. 4. tab-session.ts:onMainFrameNavigated — now clears loadedHtml, not just refs + frame. Without this, a user who load-html'd then clicked a link (or had a form submit / JS redirect / OAuth flow) would retain the stale replay metadata. The next viewport --scale would silently revert the tab to the ORIGINAL loaded HTML, losing whatever the post-navigation content was. Silent data corruption. Browser-emitted navigations trigger this path via wirePageEvents. 5. browser-manager.ts:saveState + restoreState — tab ownership now flows through BrowserState.owner. Without this, a scoped agent's viewport --scale would strand them: tab IDs change during recreate, ownership map held stale IDs, owner lookup failed. New IDs had no owner, so writes without tabId were denied (DoS). Worse, if the agent sent a stale tabId the server's swallowed-tab-switch-error path would let the command hit whatever tab was currently active (cross-tab authz bypass). Now: clear ownership before restore, re-add per-tab with new IDs. 6. meta-commands.ts:state load — disk-loaded state.pages is now explicit allowlist (url, isActive, storage:null) instead of object spread. Spreading accepted loadedHtml, loadedHtmlWaitUntil, and owner from a user-writable state file, letting a tampered state.json smuggle HTML past load-html's safe-dirs / extension / magic-byte / 50MB-cap validators, or forge tab ownership. Now stripped at the boundary. 7. url-validation.ts:normalizeFileUrl — preserves query string + fragment across normalization. file://./app.html?route=home#login previously resolved to a filesystem path that URL-encoded '?' as %3F and '#' as %23, or (for absolute forms) pathToFileURL dropped them entirely. SPAs and fixture URLs with query params 404'd or loaded the wrong route. Now: split on ?/# before path resolution, reattach after. 8. url-validation.ts:validateNavigationUrl — reattaches parsed.search + parsed.hash to the normalized file:// URL. Same fix at the main validator for absolute paths that go through fileURLToPath round-trip. 9. server.ts:writeAuditEntry — audit entries now include aliasOf when the user typed an alias ('setcontent' → cmd: 'load-html', aliasOf: 'setcontent'). Previously the isAliased variable was computed but dropped, losing the raw input from the forensic trail. Completes the plan's codex v3 P2 requirement. Also added bm.getCurrentViewport() and switched 'viewport --scale'- without-size to read from it (more reliable than page.viewportSize() on headed/transition contexts). Tests pass: exit 0, no failures. Build clean. * test: integration coverage for load-html, screenshot --selector, viewport --scale, replay, aliases Adds 28 Playwright-integration tests that close the coverage gap flagged by the ship-workflow coverage audit (50% → expected ~80%+). **load-html (12 tests):** - happy path loads HTML file, page text matches - bare HTML fragments (
...
) accepted, not just full documents - missing file arg throws usage - non-.html extension rejected by allowlist - /etc/passwd.html rejected by safe-dirs policy - ENOENT path rejected with actionable "not found" error - directory target rejected - binary file (PNG magic bytes) disguised as .html rejected by magic-byte check - UTF-8 BOM stripped before magic-byte check — BOM-prefixed HTML accepted - --wait-until networkidle exercises non-default branch - invalid --wait-until value rejected - unknown flag rejected **screenshot --selector (5 tests):** - --selector flag captures element, validates Screenshot saved (element) - conflicts with positional selector (both = error) - conflicts with --clip (mutually exclusive) - composes with --base64 (returns data:image/png;base64,...) - missing value throws usage **viewport --scale (5 tests):** - WxH --scale 2 produces PNG with 2x element dimensions (parses IHDR bytes 16-23) - --scale without WxH keeps current size + applies scale - non-finite value (abc) throws "not a finite number" - out-of-range (4, 0.5) throws "between 1 and 3" - missing value throws **setContent replay across context recreation (3 tests):** - load-html → viewport --scale 2: content survives (hits setTabContent replay path) - double cycle 2x → 1.5x: content still survives (proves TabSession rehydration) - goto after load-html clears replay: subsequent viewport --scale does NOT resurrect the stale HTML (validates the onMainFrameNavigated fix) **Command aliases (2 tests):** - setcontent routes to load-html via chain canonicalization - set-content (hyphenated) also routes — both end-to-end through chain dispatch Fixture paths use /tmp (SAFE_DIRECTORIES entry) instead of $TMPDIR which is /var/folders/... on macOS and outside the safe-dirs boundary. Chain result labels use rawName→name format when an alias is resolved (matches the meta-commands.ts chain refactor). Full suite: exit 0, 223/223 pass. * docs: update BROWSER.md + CHANGELOG for v1.1.0.0 BROWSER.md: - Command reference table updated: goto now lists file:// support, load-html added to Navigate row, viewport flagged with --scale option, screenshot row shows --selector + --base64 flags - Screenshot modes table adds the fifth mode (element crop via --selector flag) and notes the tag-selector-not-caught-positionally gotcha - New "Retina screenshots — viewport --scale" subsection explains deviceScaleFactor mechanics, context recreation side effects, and headed-mode rejection - New "Loading local HTML — goto file:// vs load-html" subsection explains the two paths, their tradeoffs (URL state, relative asset resolution), the safe-dirs policy, extension allowlist + magic-byte sniff, 50MB cap, setContent replay across recreateContext, and the alias routing (setcontent → load-html before scope check) CHANGELOG.md (v1.1.0.0 security section expanded, no existing content removed): - State files cannot smuggle HTML or forge tab ownership (allowlist on disk-loaded page fields) - Audit log records aliasOf when a canonical command was reached via an alias (setcontent → load-html) - load-html content clears on real navigations (clicks, form submits, JS redirects) — not just explicit goto. Also notes SPA query/fragment preservation for goto file:// Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- BROWSER.md | 46 +++- CHANGELOG.md | 27 +++ SKILL.md | 7 +- VERSION | 2 +- browse/SKILL.md | 58 ++++- browse/SKILL.md.tmpl | 51 ++++ browse/src/audit.ts | 4 + browse/src/browser-manager.ts | 145 ++++++++++- browse/src/commands.ts | 106 +++++++- browse/src/meta-commands.ts | 88 ++++--- browse/src/server.ts | 22 +- browse/src/tab-session.ts | 65 ++++- browse/src/token-registry.ts | 1 + browse/src/url-validation.ts | 165 ++++++++++++- browse/src/write-commands.ts | 162 ++++++++++++- browse/test/commands.test.ts | 337 ++++++++++++++++++++++++++ browse/test/dx-polish.test.ts | 101 ++++++++ browse/test/security-audit-r2.test.ts | 5 +- browse/test/url-validation.test.ts | 137 +++++++++-- package.json | 2 +- 20 files changed, 1439 insertions(+), 92 deletions(-) create mode 100644 browse/test/dx-polish.test.ts diff --git a/BROWSER.md b/BROWSER.md index d8a390be..169808fb 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -6,13 +6,13 @@ This document covers the command reference and internals of gstack's headless br | Category | Commands | What for | |----------|----------|----------| -| Navigate | `goto`, `back`, `forward`, `reload`, `url` | Get to a page | +| Navigate | `goto` (accepts `http://`, `https://`, `file://`), `load-html`, `back`, `forward`, `reload`, `url` | Get to a page, including local HTML | | Read | `text`, `html`, `links`, `forms`, `accessibility` | Extract content | | Snapshot | `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o] [-C]` | Get refs, diff, annotate | -| Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport`, `upload` | Use the page | +| Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport [WxH] [--scale N]`, `upload` | Use the page (scale = deviceScaleFactor for retina) | | Inspect | `js`, `eval`, `css`, `attrs`, `is`, `console`, `network`, `dialog`, `cookies`, `storage`, `perf`, `inspect [selector] [--all]` | Debug and verify | | Style | `style `, `style --undo [N]`, `cleanup [--all]`, `prettyscreenshot` | Live CSS editing and page cleanup | -| Visual | `screenshot [--viewport] [--clip x,y,w,h] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees | +| Visual | `screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees | | Compare | `diff ` | Spot differences between environments | | Dialogs | `dialog-accept [text]`, `dialog-dismiss` | Control alert/confirm/prompt handling | | Tabs | `tabs`, `tab`, `newtab`, `closetab` | Multi-page workflows | @@ -100,18 +100,50 @@ No DOM mutation. No injected scripts. Just Playwright's native accessibility API ### Screenshot modes -The `screenshot` command supports four modes: +The `screenshot` command supports five modes: | Mode | Syntax | Playwright API | |------|--------|----------------| | Full page (default) | `screenshot [path]` | `page.screenshot({ fullPage: true })` | | Viewport only | `screenshot --viewport [path]` | `page.screenshot({ fullPage: false })` | -| Element crop | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` | +| Element crop (flag) | `screenshot --selector [path]` | `locator.screenshot()` | +| Element crop (positional) | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` | | Region clip | `screenshot --clip x,y,w,h [path]` | `page.screenshot({ clip })` | -Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c` refs from `snapshot`. Auto-detection: `@e`/`@c` prefix = ref, `.`/`#`/`[` prefix = CSS selector, `--` prefix = flag, everything else = output path. +Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c` refs from `snapshot`. Auto-detection for positional: `@e`/`@c` prefix = ref, `.`/`#`/`[` prefix = CSS selector, `--` prefix = flag, everything else = output path. **Tag selectors like `button` aren't caught by the positional heuristic** — use the `--selector` flag form. -Mutual exclusion: `--clip` + selector and `--viewport` + `--clip` both throw errors. Unknown flags (e.g. `--bogus`) also throw. +The `--base64` flag returns `data:image/png;base64,...` instead of writing to disk — composes with `--selector`, `--clip`, and `--viewport`. + +Mutual exclusion: `--clip` + selector (flag or positional), `--viewport` + `--clip`, and `--selector` + positional selector all throw. Unknown flags (e.g. `--bogus`) also throw. + +### Retina screenshots — viewport `--scale` + +`viewport --scale ` sets Playwright's `deviceScaleFactor` (context-level option, 1-3 gstack policy cap). A 2x scale doubles the pixel density of screenshots: + +```bash +$B viewport 480x600 --scale 2 +$B load-html /tmp/card.html +$B screenshot /tmp/card.png --selector .card +# .card element at 400x200 CSS pixels → card.png is 800x400 pixels +``` + +`viewport --scale N` alone (no `WxH`) keeps the current viewport size and only changes the scale. Scale changes trigger a browser context recreation (Playwright requirement), which invalidates `@e`/`@c` refs — rerun `snapshot` after. HTML loaded via `load-html` survives the recreation via in-memory replay (see below). Rejected in headed mode since scale is controlled by the real browser window. + +### Loading local HTML — `goto file://` vs `load-html` + +Two ways to render HTML that isn't on a web server: + +| Approach | When | URL after | Relative assets | +|----------|------|-----------|-----------------| +| `goto file://` | File already on disk | `file:///...` | Resolve against file's directory | +| `goto file://./`, `goto file://~/`, `goto file://` | Smart-parsed to absolute | `file:///...` | Same | +| `load-html ` | HTML generated in memory | `about:blank` | Broken (self-contained HTML only) | + +Both are scoped to files under cwd or `$TMPDIR` via the same safe-dirs policy as the `eval` command. `file://` URLs preserve query strings and fragments (SPA routes work). `load-html` has an extension allowlist (`.html/.htm/.xhtml/.svg`) and a magic-byte sniff to reject binary files mis-renamed as HTML, plus a 50 MB size cap (override via `GSTACK_BROWSE_MAX_HTML_BYTES`). + +`load-html` content survives later `viewport --scale` calls via in-memory replay (TabSession tracks the loaded HTML + waitUntil). The replay is purely in-memory — HTML is never persisted to disk via `state save` to avoid leaking secrets or customer data. + +Aliases: `setcontent`, `set-content`, and `setContent` all route to `load-html` via the server's alias canonicalization (happens before scope checks, so a read-scoped token still can't use the alias to run a write command). ### Batch endpoint diff --git a/CHANGELOG.md b/CHANGELOG.md index ac13e0db..b31735b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.1.0.0] - 2026-04-18 + +### Added +- **Browse can now render local HTML without an HTTP server.** Two ways: `$B goto file:///tmp/report.html` navigates to a local file (including cwd-relative `file://./x` and home-relative `file://~/x` forms, smart-parsed so you don't have to think about URL grammar), or `$B load-html /tmp/tweet.html` reads the file and loads it via `page.setContent()`. Both are scoped to cwd + temp dir for safety. If you're migrating a Puppeteer script that generates HTML in memory, this kills your Python-HTTP-server workaround. +- **Element screenshots with an explicit flag.** `$B screenshot out.png --selector .card` is now the unambiguous way to screenshot a single element. Positional selectors still work, but tag selectors like `button` weren't recognized positionally, so the flag form fixes that. `--selector` composes with `--base64` and rejects alongside `--clip` (choose one). +- **Retina screenshots via `--scale`.** `$B viewport 480x2000 --scale 2` sets `deviceScaleFactor: 2` and produces pixel-doubled screenshots. `$B viewport --scale 2` alone changes just the scale factor and keeps the current size. Scale is capped at 1-3 (gstack policy). Headed mode rejects the flag since scale is controlled by the real browser window. +- **Load-HTML content survives scale changes.** Changing `--scale` rebuilds the browser context (that's how Playwright works), which previously would have wiped pages loaded via `load-html`. Now the HTML is cached in tab state and replayed into the new context automatically. In-memory only; never persisted to disk. +- **Puppeteer → browse cheatsheet in SKILL.md.** Side-by-side table of Puppeteer APIs mapped to browse commands, plus a full worked example (tweet-renderer flow: viewport + scale + load-html + element screenshot). +- **Guess-friendly aliases.** Type `setcontent` or `set-content` and it routes to `load-html`. Canonicalization happens before scope checks, so read-scoped tokens can't use the alias to bypass write-scope enforcement. +- **`Did you mean ...?` on unknown commands.** `$B load-htm` returns `Unknown command: 'load-htm'. Did you mean 'load-html'?`. Levenshtein match within distance 2, gated on input length ≥ 4 so 2-letter typos don't produce noise. +- **Rich, actionable errors on `load-html`.** Every rejection path (file not found, directory, oversize, outside safe dirs, binary content, frame context) names the input, explains the cause, and says what to do next. Extension allowlist `.html/.htm/.xhtml/.svg` + magic-byte sniff (with UTF-8 BOM strip) catches mis-renamed binaries before they render as garbage. + +### Security +- `file://` navigation is now an accepted scheme in `goto`, scoped to cwd + temp dir via the existing `validateReadPath()` policy. UNC/network hosts (`file://host.example.com/...`), IP hosts, IPv6 hosts, and Windows drive-letter hosts are all rejected with explicit errors. +- **State files can no longer smuggle HTML content.** `state load` now uses an explicit allowlist for the fields it accepts from disk — a tampered state file cannot inject `loadedHtml` to bypass the `load-html` safe-dirs, extension allowlist, magic-byte sniff, or size cap checks. Tab ownership is preserved across context recreation via the same in-memory channel, closing a cross-agent authorization gap where scoped agents could lose (or gain) tabs after `viewport --scale`. +- **Audit log now records the raw alias input.** When you type `setcontent`, the audit entry shows `cmd: load-html, aliasOf: setcontent` so the forensic trail reflects what the agent actually sent, not just the canonical form. +- **`load-html` content correctly clears on every real navigation** — link clicks, form submits, and JavaScript redirects now invalidate the replay metadata just like explicit `goto`/`back`/`forward`/`reload` do. Previously a later `viewport --scale` after a click could resurrect the original `load-html` content (silent data corruption). Also fixes SPA fixture URLs: `goto file:///tmp/app.html?route=home#login` preserves the query string and fragment through normalization. + +### For contributors +- `validateNavigationUrl()` now returns the normalized URL (previously void). All four callers — goto, diff, newTab, restoreState — updated to consume the return value so smart-parsing takes effect at every navigation site. +- New `normalizeFileUrl()` helper uses `fileURLToPath()` + `pathToFileURL()` from `node:url` — never string-concat — so URL escapes like `%20` decode correctly and encoded-slash traversal (`%2F..%2F`) is rejected by Node outright. +- New `TabSession.loadedHtml` field + `setTabContent()` / `getLoadedHtml()` / `clearLoadedHtml()` methods. ASCII lifecycle diagram in the source. The `clear` call happens BEFORE navigation starts (not after) so a goto that times out post-commit doesn't leave stale metadata that could resurrect on a later context recreation. +- `BrowserManager.setDeviceScaleFactor(scale, w, h)` is atomic: validates input, stores new values, calls `recreateContext()`, rolls back the fields on failure. `currentViewport` tracking means recreateContext preserves your size instead of hardcoding 1280×720. +- `COMMAND_ALIASES` + `canonicalizeCommand()` + `buildUnknownCommandError()` + `NEW_IN_VERSION` are exported from `browse/src/commands.ts`. Single source of truth — both the server dispatcher and `chain` prevalidation import from the same place. Chain uses `{ rawName, name }` shape per step so audit logs preserve what the user typed while dispatch uses the canonical name. +- `load-html` is registered in `SCOPE_WRITE` in `browse/src/token-registry.ts`. +- Review history for the curious: 3 Codex consults (20 + 10 + 6 gaps), DX review (TTHW ~4min → <60s, Champion tier), 2 Eng review passes. Third Codex pass caught the 4-caller bug for `validateNavigationUrl` that the eng passes missed. All findings folded into the plan. + ## [1.0.0.0] - 2026-04-18 ### Added diff --git a/SKILL.md b/SKILL.md index 4d3b1d41..33f479d2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -797,7 +797,8 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. |---------|-------------| | `back` | History back | | `forward` | History forward | -| `goto ` | Navigate to URL | +| `goto ` | Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) | +| `load-html [--wait-until load|domcontentloaded|networkidle]` | Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner. | | `reload` | Reload page | | `url` | Print current URL | @@ -848,7 +849,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `type ` | Type into focused element | | `upload [file2...]` | Upload file(s) | | `useragent ` | Set user agent | -| `viewport ` | Set viewport size | +| `viewport [] [--scale ]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. | | `wait ` | Wait for element, network idle, or page load (timeout: 15s) | ### Inspection @@ -875,7 +876,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `pdf [path]` | Save as PDF | | `prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]` | Clean screenshot with optional cleanup, scroll positioning, and element hiding | | `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. | -| `screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]` | Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport) | +| `screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]` | Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work. | ### Snapshot | Command | Description | diff --git a/VERSION b/VERSION index 1921233b..a6bbdb5f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0.0 +1.1.0.0 diff --git a/browse/SKILL.md b/browse/SKILL.md index d112a9d4..23b32a85 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -584,6 +584,57 @@ $B diff https://staging.app.com https://prod.app.com ### 11. Show screenshots to the user After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible. +### 12. Render local HTML (no HTTP server needed) +Two paths, pick the cleaner one: +```bash +# HTML file on disk → goto file:// (absolute, or cwd-relative) +$B goto file:///tmp/report.html +$B goto file://./docs/page.html # cwd-relative +$B goto file://~/Documents/page.html # home-relative + +# HTML generated in memory → load-html reads the file into setContent +echo '
hello
' > /tmp/tweet.html +$B load-html /tmp/tweet.html +``` + +`goto file://...` is usually cleaner (URL is saved in state, relative asset URLs resolve against the file's dir, scale changes replay naturally). `load-html` uses `page.setContent()` — URL stays `about:blank`, but the content survives `viewport --scale` via in-memory replay. Both are scoped to files under cwd or `$TMPDIR`. + +### 13. Retina screenshots (deviceScaleFactor) +```bash +$B viewport 480x600 --scale 2 # 2x deviceScaleFactor +$B load-html /tmp/tweet.html # or: $B goto file://./tweet.html +$B screenshot /tmp/out.png --selector .tweet-card +# → /tmp/out.png is 2x the pixel dimensions of the element +``` +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. + +## Puppeteer → browse cheatsheet + +Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: + +| Puppeteer | browse | +|---|---| +| `await page.goto(url)` | `$B goto ` | +| `await page.setContent(html)` | `$B load-html ` (or `$B goto file://`) | +| `await page.setViewport({width, height})` | `$B viewport WxH` | +| `await page.setViewport({width, height, deviceScaleFactor: 2})` | `$B viewport WxH --scale 2` | +| `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` | + +Worked example (the tweet-renderer flow — Puppeteer → browse): + +```bash +# Generate HTML in memory, render at 2x scale, screenshot the tweet card. +echo '
hello
' > /tmp/tweet.html +$B viewport 480x600 --scale 2 +$B load-html /tmp/tweet.html +$B screenshot /tmp/out.png --selector .tweet-card +# /tmp/out.png is 800x400 px, crisp (2x deviceScaleFactor). +``` + +Aliases: typing `setcontent` or `set-content` routes to `load-html` automatically. Typing a typo (`load-htm`) returns `Did you mean 'load-html'?`. + ## User Handoff When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor @@ -688,7 +739,8 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero |---------|-------------| | `back` | History back | | `forward` | History forward | -| `goto ` | Navigate to URL | +| `goto ` | Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) | +| `load-html [--wait-until load|domcontentloaded|networkidle]` | Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner. | | `reload` | Reload page | | `url` | Print current URL | @@ -739,7 +791,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | `type ` | Type into focused element | | `upload [file2...]` | Upload file(s) | | `useragent ` | Set user agent | -| `viewport ` | Set viewport size | +| `viewport [] [--scale ]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. | | `wait ` | Wait for element, network idle, or page load (timeout: 15s) | ### Inspection @@ -766,7 +818,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | `pdf [path]` | Save as PDF | | `prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]` | Clean screenshot with optional cleanup, scroll positioning, and element hiding | | `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. | -| `screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]` | Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport) | +| `screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]` | Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work. | ### Snapshot | Command | Description | diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 5d4ba8fc..ec4fcad7 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -111,6 +111,57 @@ $B diff https://staging.app.com https://prod.app.com ### 11. Show screenshots to the user After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible. +### 12. Render local HTML (no HTTP server needed) +Two paths, pick the cleaner one: +```bash +# HTML file on disk → goto file:// (absolute, or cwd-relative) +$B goto file:///tmp/report.html +$B goto file://./docs/page.html # cwd-relative +$B goto file://~/Documents/page.html # home-relative + +# HTML generated in memory → load-html reads the file into setContent +echo '
hello
' > /tmp/tweet.html +$B load-html /tmp/tweet.html +``` + +`goto file://...` is usually cleaner (URL is saved in state, relative asset URLs resolve against the file's dir, scale changes replay naturally). `load-html` uses `page.setContent()` — URL stays `about:blank`, but the content survives `viewport --scale` via in-memory replay. Both are scoped to files under cwd or `$TMPDIR`. + +### 13. Retina screenshots (deviceScaleFactor) +```bash +$B viewport 480x600 --scale 2 # 2x deviceScaleFactor +$B load-html /tmp/tweet.html # or: $B goto file://./tweet.html +$B screenshot /tmp/out.png --selector .tweet-card +# → /tmp/out.png is 2x the pixel dimensions of the element +``` +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. + +## Puppeteer → browse cheatsheet + +Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: + +| Puppeteer | browse | +|---|---| +| `await page.goto(url)` | `$B goto ` | +| `await page.setContent(html)` | `$B load-html ` (or `$B goto file://`) | +| `await page.setViewport({width, height})` | `$B viewport WxH` | +| `await page.setViewport({width, height, deviceScaleFactor: 2})` | `$B viewport WxH --scale 2` | +| `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` | + +Worked example (the tweet-renderer flow — Puppeteer → browse): + +```bash +# Generate HTML in memory, render at 2x scale, screenshot the tweet card. +echo '
hello
' > /tmp/tweet.html +$B viewport 480x600 --scale 2 +$B load-html /tmp/tweet.html +$B screenshot /tmp/out.png --selector .tweet-card +# /tmp/out.png is 800x400 px, crisp (2x deviceScaleFactor). +``` + +Aliases: typing `setcontent` or `set-content` routes to `load-html` automatically. Typing a typo (`load-htm`) returns `Did you mean 'load-html'?`. + ## User Handoff When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor diff --git a/browse/src/audit.ts b/browse/src/audit.ts index 5ac59f6d..b6e54638 100644 --- a/browse/src/audit.ts +++ b/browse/src/audit.ts @@ -18,6 +18,9 @@ import * as fs from 'fs'; export interface AuditEntry { ts: string; cmd: string; + /** If the agent typed an alias (e.g. 'setcontent'), the raw input is preserved here + * while `cmd` holds the canonical name ('load-html'). Omitted when cmd === rawCmd. */ + aliasOf?: string; args: string; origin: string; durationMs: number; @@ -56,6 +59,7 @@ export function writeAuditEntry(entry: AuditEntry): void { hasCookies: entry.hasCookies, mode: entry.mode, }; + if (entry.aliasOf) record.aliasOf = entry.aliasOf; if (truncatedError) record.error = truncatedError; fs.appendFileSync(auditPath, JSON.stringify(record) + '\n'); diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 6b9242da..2885d1cc 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -31,6 +31,18 @@ export interface BrowserState { url: string; isActive: boolean; storage: { localStorage: Record; sessionStorage: Record } | null; + /** + * HTML content loaded via load-html (setContent), replayed after context recreation. + * In-memory only — never persisted to disk (HTML may contain secrets or customer data). + */ + loadedHtml?: string; + loadedHtmlWaitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + /** + * Tab owner clientId for multi-agent isolation. Survives context recreation so + * scoped agents don't get locked out of their own tabs after viewport --scale. + * In-memory only. + */ + owner?: string; }>; } @@ -44,6 +56,14 @@ export class BrowserManager { private extraHeaders: Record = {}; private customUserAgent: string | null = null; + // ─── Viewport + deviceScaleFactor (context options) ────────── + // Tracked at the manager level so recreateContext() preserves them. + // deviceScaleFactor is a *context* option, not a page-level setter — changes + // require recreateContext(). Viewport width/height can change on-page, but we + // track the latest so context recreation restores it instead of hardcoding 1280x720. + private deviceScaleFactor: number = 1; + private currentViewport: { width: number; height: number } = { width: 1280, height: 720 }; + /** Server port — set after server starts, used by cookie-import-browser command */ public serverPort: number = 0; @@ -197,7 +217,8 @@ export class BrowserManager { }); const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, + deviceScaleFactor: this.deviceScaleFactor, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; @@ -550,9 +571,12 @@ export class BrowserManager { async newTab(url?: string, clientId?: string): Promise { if (!this.context) throw new Error('Browser not launched'); - // Validate URL before allocating page to avoid zombie tabs on rejection + // Validate URL before allocating page to avoid zombie tabs on rejection. + // Use the normalized return value for navigation — it handles file://./x and + // file:// cwd-relative forms that the standard URL parser doesn't. + let normalizedUrl: string | undefined; if (url) { - await validateNavigationUrl(url); + normalizedUrl = await validateNavigationUrl(url); } const page = await this.context.newPage(); @@ -569,8 +593,8 @@ export class BrowserManager { // Wire up console/network/dialog capture this.wirePageEvents(page); - if (url) { - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + if (normalizedUrl) { + await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); } return id; @@ -792,6 +816,7 @@ export class BrowserManager { // ─── Viewport ────────────────────────────────────────────── async setViewport(width: number, height: number) { + this.currentViewport = { width, height }; await this.getPage().setViewportSize({ width, height }); } @@ -858,10 +883,21 @@ export class BrowserManager { sessionStorage: { ...sessionStorage }, })); } catch {} + + // Capture load-html content so a later context recreation (viewport --scale) + // can replay it via setTabContent. Never persisted to disk. + const session = this.tabSessions.get(id); + const loaded = session?.getLoadedHtml(); + // Preserve tab ownership through recreation so scoped agents aren't locked out. + const owner = this.tabOwnership.get(id); + pages.push({ url: url === 'about:blank' ? '' : url, isActive: id === this.activeTabId, storage, + loadedHtml: loaded?.html, + loadedHtmlWaitUntil: loaded?.waitUntil, + owner, }); } @@ -881,25 +917,49 @@ export class BrowserManager { await this.context.addCookies(state.cookies); } + // Clear stale ownership — the old tab IDs are gone. We'll re-add per-tab + // owners below as each saved tab gets a fresh ID. Without this reset, old + // tabId → clientId entries would linger and match new tabs with the same + // sequential IDs, silently granting ownership to the wrong clients. + this.tabOwnership.clear(); + // Re-create pages let activeId: number | null = null; for (const saved of state.pages) { const page = await this.context.newPage(); const id = this.nextTabId++; this.pages.set(id, page); - this.tabSessions.set(id, new TabSession(page)); + const newSession = new TabSession(page); + this.tabSessions.set(id, newSession); this.wirePageEvents(page); - if (saved.url) { - // Validate the saved URL before navigating — the state file is user-writable and - // a tampered URL could navigate to cloud metadata endpoints or file:// URIs. + // Restore tab ownership for the new ID — preserves scoped-agent isolation + // across context recreation (viewport --scale, user-agent change, handoff). + if (saved.owner) { + this.tabOwnership.set(id, saved.owner); + } + + if (saved.loadedHtml) { + // Replay load-html content via setTabContent — this rehydrates + // TabSession.loadedHtml so the next saveState sees it. page.setContent() + // alone would restore the DOM but lose the replay metadata. try { - await validateNavigationUrl(saved.url); + await newSession.setTabContent(saved.loadedHtml, { waitUntil: saved.loadedHtmlWaitUntil }); + } catch (err: any) { + console.warn(`[browse] Failed to replay loadedHtml for tab ${id}: ${err.message}`); + } + } else if (saved.url) { + // Validate the saved URL before navigating — the state file is user-writable and + // a tampered URL could navigate to cloud metadata endpoints. Use the normalized + // return value so file:// forms get consistent treatment with live goto. + let normalizedUrl: string; + try { + normalizedUrl = await validateNavigationUrl(saved.url); } catch (err: any) { console.warn(`[browse] Skipping invalid URL in state file: ${saved.url} — ${err.message}`); continue; } - await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); + await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); } if (saved.storage) { @@ -960,7 +1020,8 @@ export class BrowserManager { // 3. Create new context with updated settings const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, + deviceScaleFactor: this.deviceScaleFactor, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; @@ -983,7 +1044,8 @@ export class BrowserManager { if (this.context) await this.context.close().catch(() => {}); const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, + deviceScaleFactor: this.deviceScaleFactor, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; @@ -998,6 +1060,63 @@ export class BrowserManager { } } + /** + * Change deviceScaleFactor + viewport size atomically. + * + * deviceScaleFactor is a context-level option, so Playwright requires a full context + * recreation. This method validates the input, stores the new values, calls + * recreateContext(), and rolls back the fields on failure so a bad call doesn't + * leave the manager in an inconsistent state. + * + * Returns null on success, or an error string if the new context couldn't be built + * (state may have been lost, per recreateContext's fallback behavior). + */ + async setDeviceScaleFactor(scale: number, width: number, height: number): Promise { + if (!Number.isFinite(scale)) { + throw new Error(`viewport --scale: value must be a finite number, got ${scale}`); + } + if (scale < 1 || scale > 3) { + throw new Error(`viewport --scale: value must be between 1 and 3 (gstack policy cap), got ${scale}`); + } + if (this.connectionMode === 'headed') { + throw new Error('viewport --scale is not supported in headed mode — scale is controlled by the real browser window.'); + } + + const prevScale = this.deviceScaleFactor; + const prevViewport = { ...this.currentViewport }; + this.deviceScaleFactor = scale; + this.currentViewport = { width, height }; + + const err = await this.recreateContext(); + if (err !== null) { + // recreateContext's fallback path built a blank context using the NEW scale + + // viewport (the fields we just set). Rolling the fields back without a second + // recreate would leave the live context at new-scale while state says old-scale. + // Roll back fields FIRST, then force a second recreate against the old values + // so live state matches tracked state. + this.deviceScaleFactor = prevScale; + this.currentViewport = prevViewport; + const rollbackErr = await this.recreateContext(); + if (rollbackErr !== null) { + // Second recreate also failed — we're in a clean blank slate via fallback, but + // with old scale. Return the original error so the caller sees the primary failure. + return `${err} (rollback also encountered: ${rollbackErr})`; + } + return err; + } + return null; + } + + /** Read current deviceScaleFactor (for tests + debug). */ + getDeviceScaleFactor(): number { + return this.deviceScaleFactor; + } + + /** Read current tracked viewport (for tests + `viewport --scale` size fallback). */ + getCurrentViewport(): { width: number; height: number } { + return { ...this.currentViewport }; + } + // ─── Handoff: Headless → Headed ───────────────────────────── /** * Hand off browser control to the user by relaunching in headed mode. diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 2fd0b421..22c30694 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -21,6 +21,7 @@ export const READ_COMMANDS = new Set([ export const WRITE_COMMANDS = new Set([ 'goto', 'back', 'forward', 'reload', + 'load-html', 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', @@ -64,7 +65,8 @@ export function wrapUntrustedContent(result: string, url: string): string { export const COMMAND_DESCRIPTIONS: Record = { // Navigation - 'goto': { category: 'Navigation', description: 'Navigate to URL', usage: 'goto ' }, + 'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto ' }, + 'load-html': { category: 'Navigation', description: 'Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner.', usage: 'load-html [--wait-until load|domcontentloaded|networkidle]' }, 'back': { category: 'Navigation', description: 'History back' }, 'forward': { category: 'Navigation', description: 'History forward' }, 'reload': { category: 'Navigation', description: 'Reload page' }, @@ -99,7 +101,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload [file2...]' }, - 'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport ' }, + 'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [] [--scale ]' }, 'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie =' }, 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, 'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' }, @@ -112,7 +114,7 @@ export const COMMAND_DESCRIPTIONS: Record [--selector sel] [--dir path] [--limit N]' }, 'archive': { category: 'Extraction', description: 'Save complete page as MHTML via CDP', usage: 'archive [path]' }, // Visual - 'screenshot': { category: 'Visual', description: 'Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)', usage: 'screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]' }, + 'screenshot': { category: 'Visual', description: 'Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work.', usage: 'screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]' }, 'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' }, 'responsive': { category: 'Visual', description: 'Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.', usage: 'responsive [prefix]' }, 'diff': { category: 'Visual', description: 'Text diff between pages', usage: 'diff ' }, @@ -161,3 +163,101 @@ for (const cmd of allCmds) { for (const key of descKeys) { if (!allCmds.has(key)) throw new Error(`COMMAND_DESCRIPTIONS has unknown command: ${key}`); } + +/** + * Command aliases — user-friendly names that route to canonical commands. + * + * Single source of truth: server.ts dispatch and meta-commands.ts chain prevalidation + * both import `canonicalizeCommand()`, so aliases resolve identically everywhere. + * + * When adding a new alias: keep the alias name guessable (e.g. setcontent → load-html + * helps agents migrating from Puppeteer's page.setContent()). + */ +export const COMMAND_ALIASES: Record = { + 'setcontent': 'load-html', + 'set-content': 'load-html', + 'setContent': 'load-html', +}; + +/** Resolve an alias to its canonical command name. Non-aliases pass through unchanged. */ +export function canonicalizeCommand(cmd: string): string { + return COMMAND_ALIASES[cmd] ?? cmd; +} + +/** + * Commands added in specific versions — enables future "this command was added in vX" + * upgrade hints in unknown-command errors. Only helps agents on *newer* browse builds + * that encounter typos of recently-added commands; does NOT help agents on old builds + * that type a new command (they don't have this map). + */ +export const NEW_IN_VERSION: Record = { + 'load-html': '0.19.0.0', +}; + +/** + * Levenshtein distance (dynamic programming). + * O(a.length * b.length) — fast for command name sizes (<20 chars). + */ +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const m: number[][] = []; + for (let i = 0; i <= a.length; i++) m.push([i, ...Array(b.length).fill(0)]); + for (let j = 0; j <= b.length; j++) m[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + cost); + } + } + return m[a.length][b.length]; +} + +/** + * Build an actionable error message for an unknown command. + * + * Pure function — takes the full command set + alias map + version map as args so tests + * can exercise the synthetic "older-version" case without mutating any global state. + * + * 1. Always names the input. + * 2. If Levenshtein distance ≤ 2 AND input.length ≥ 4, suggests the closest match + * (alphabetical tiebreak for determinism). Short-input guard prevents noisy + * suggestions for typos of 2-letter commands like 'js' or 'is'. + * 3. If the input appears in newInVersion, appends an upgrade hint. Honesty caveat: + * this only fires on builds that have this handler AND the map entry; agents on + * older builds hitting a newly-added command won't see it. Net benefit compounds + * as more commands land. + */ +export function buildUnknownCommandError( + command: string, + commandSet: Set, + aliasMap: Record = COMMAND_ALIASES, + newInVersion: Record = NEW_IN_VERSION, +): string { + let msg = `Unknown command: '${command}'.`; + + // Suggestion via Levenshtein, gated on input length to avoid noisy short-input matches. + // Candidates are pre-sorted alphabetically, so strict "d < bestDist" gives us the + // closest match with alphabetical tiebreak for free — first equal-distance candidate + // wins because subsequent equal-distance candidates fail the strict-less check. + if (command.length >= 4) { + let best: string | undefined; + let bestDist = 3; // sentinel: distance 3 would be rejected by the <= 2 gate below + const candidates = [...commandSet, ...Object.keys(aliasMap)].sort(); + for (const cand of candidates) { + const d = levenshtein(command, cand); + if (d <= 2 && d < bestDist) { + best = cand; + bestDist = d; + } + } + if (best) msg += ` Did you mean '${best}'?`; + } + + if (newInVersion[command]) { + msg += ` This command was added in browse v${newInVersion[command]}. Upgrade: cd ~/.claude/skills/gstack && git pull && bun run build.`; + } + + return msg; +} diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 392602f0..6eb597c9 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -5,7 +5,7 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; import { getCleanText } from './read-commands'; -import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands'; +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand } from './commands'; import { validateNavigationUrl } from './url-validation'; import { checkScope, type TokenInfo } from './token-registry'; import { validateOutputPath, escapeRegExp } from './path-security'; @@ -124,11 +124,15 @@ export async function handleMetaCommand( let base64Mode = false; const remaining: string[] = []; + let flagSelector: string | undefined; for (let i = 0; i < args.length; i++) { if (args[i] === '--viewport') { viewportOnly = true; } else if (args[i] === '--base64') { base64Mode = true; + } else if (args[i] === '--selector') { + flagSelector = args[++i]; + if (!flagSelector) throw new Error('Usage: screenshot --selector [path]'); } else if (args[i] === '--clip') { const coords = args[++i]; if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]'); @@ -156,6 +160,14 @@ export async function handleMetaCommand( } } + // --selector flag takes precedence; conflict with positional selector. + if (flagSelector !== undefined) { + if (targetSelector !== undefined) { + throw new Error('--selector conflicts with positional selector — choose one'); + } + targetSelector = flagSelector; + } + validateOutputPath(outputPath); if (clipRect && targetSelector) { @@ -244,27 +256,36 @@ export async function handleMetaCommand( ' or: browse chain \'goto url | click @e5 | snapshot -ic\'' ); - let commands: string[][]; + let rawCommands: string[][]; try { - commands = JSON.parse(jsonStr); - if (!Array.isArray(commands)) throw new Error('not array'); + rawCommands = JSON.parse(jsonStr); + if (!Array.isArray(rawCommands)) throw new Error('not array'); } catch (err: any) { // Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic" if (!(err instanceof SyntaxError) && err?.message !== 'not array') throw err; - commands = jsonStr.split(' | ') + rawCommands = jsonStr.split(' | ') .filter(seg => seg.trim().length > 0) .map(seg => tokenizePipeSegment(seg.trim())); } + // Canonicalize aliases across the whole chain. Pair canonical name with the raw + // input so result labels + error messages reflect what the user typed, but every + // dispatch path (scope check, WRITE_COMMANDS.has, watch blocking, handler lookup) + // uses the canonical name. Otherwise `chain '[["setcontent","/tmp/x.html"]]'` + // bypasses prevalidation or runs under the wrong command set. + const commands = rawCommands.map(cmd => { + const [rawName, ...cmdArgs] = cmd; + const name = canonicalizeCommand(rawName); + return { rawName, name, args: cmdArgs }; + }); + // Pre-validate ALL subcommands against the token's scope before executing any. - // This prevents partial execution where some subcommands succeed before a - // scope violation is hit, leaving the browser in an inconsistent state. + // Uses canonical name so aliases don't bypass scope checks. if (tokenInfo && tokenInfo.clientId !== 'root') { - for (const cmd of commands) { - const [name] = cmd; - if (!checkScope(tokenInfo, name)) { + for (const c of commands) { + if (!checkScope(tokenInfo, c.name)) { throw new Error( - `Chain rejected: subcommand "${name}" not allowed by your token scope (${tokenInfo.scopes.join(', ')}). ` + + `Chain rejected: subcommand "${c.rawName}" not allowed by your token scope (${tokenInfo.scopes.join(', ')}). ` + `All subcommands must be within scope.` ); } @@ -280,30 +301,33 @@ export async function handleMetaCommand( let lastWasWrite = false; if (executeCmd) { - // Full security pipeline via handleCommandInternal - for (const cmd of commands) { - const [name, ...cmdArgs] = cmd; + // Full security pipeline via handleCommandInternal. + // Pass rawName so the server's own canonicalization is a no-op (already canonical). + for (const c of commands) { const cr = await executeCmd( - { command: name, args: cmdArgs }, + { command: c.name, args: c.args }, tokenInfo, ); + const label = c.rawName === c.name ? c.name : `${c.rawName}→${c.name}`; if (cr.status === 200) { - results.push(`[${name}] ${cr.result}`); + results.push(`[${label}] ${cr.result}`); } else { // Parse error from JSON result let errMsg = cr.result; try { errMsg = JSON.parse(cr.result).error || cr.result; } catch (err: any) { if (!(err instanceof SyntaxError)) throw err; } - results.push(`[${name}] ERROR: ${errMsg}`); + results.push(`[${label}] ERROR: ${errMsg}`); } - lastWasWrite = WRITE_COMMANDS.has(name); + lastWasWrite = WRITE_COMMANDS.has(c.name); } } else { // Fallback: direct dispatch (CLI mode, no server context) const { handleReadCommand } = await import('./read-commands'); const { handleWriteCommand } = await import('./write-commands'); - for (const cmd of commands) { - const [name, ...cmdArgs] = cmd; + for (const c of commands) { + const name = c.name; + const cmdArgs = c.args; + const label = c.rawName === name ? name : `${c.rawName}→${name}`; try { let result: string; if (WRITE_COMMANDS.has(name)) { @@ -323,11 +347,11 @@ export async function handleMetaCommand( result = await handleMetaCommand(name, cmdArgs, bm, shutdown, tokenInfo, opts); lastWasWrite = false; } else { - throw new Error(`Unknown command: ${name}`); + throw new Error(`Unknown command: ${c.rawName}`); } - results.push(`[${name}] ${result}`); + results.push(`[${label}] ${result}`); } catch (err: any) { - results.push(`[${name}] ERROR: ${err.message}`); + results.push(`[${label}] ERROR: ${err.message}`); } } } @@ -346,12 +370,12 @@ export async function handleMetaCommand( if (!url1 || !url2) throw new Error('Usage: browse diff '); const page = bm.getPage(); - await validateNavigationUrl(url1); - await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const normalizedUrl1 = await validateNavigationUrl(url1); + await page.goto(normalizedUrl1, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text1 = await getCleanText(page); - await validateNavigationUrl(url2); - await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const normalizedUrl2 = await validateNavigationUrl(url2); + await page.goto(normalizedUrl2, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text2 = await getCleanText(page); const changes = Diff.diffLines(text1, text2); @@ -608,9 +632,17 @@ export async function handleMetaCommand( // Close existing pages, then restore (replace, not merge) bm.setFrame(null); await bm.closeAllPages(); + // Allowlist disk-loaded page fields — NEVER accept loadedHtml, loadedHtmlWaitUntil, + // or owner from disk. Those are in-memory-only invariants; allowing them would let + // a tampered state file smuggle HTML past load-html's safe-dirs + magic-byte + size + // checks, or forge tab ownership for cross-agent authorization bypass. await bm.restoreState({ cookies: validatedCookies, - pages: data.pages.map((p: any) => ({ ...p, storage: null })), + pages: data.pages.map((p: any) => ({ + url: typeof p.url === 'string' ? p.url : '', + isActive: Boolean(p.isActive), + storage: null, + })), }); return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`; } diff --git a/browse/src/server.ts b/browse/src/server.ts index 573a73d5..3a825c1e 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes'; import { sanitizeExtensionUrl } from './sidebar-utils'; -import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands'; +import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; import { wrapUntrustedPageContent, datamarkContent, runContentFilters, type ContentFilterResult, @@ -916,12 +916,21 @@ async function handleCommandInternal( tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { - const { command, args = [], tabId } = body; + const { args = [], tabId } = body; + const rawCommand = body.command; - if (!command) { + if (!rawCommand) { return { status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), json: true }; } + // ─── Alias canonicalization (before scope, watch, tab-ownership, dispatch) ─ + // Agent-friendly names like 'setcontent' route to canonical 'load-html'. Must + // happen BEFORE scope check so a read-scoped token calling 'setcontent' is still + // rejected (load-html lives in SCOPE_WRITE). Audit logging preserves rawCommand + // so the trail records what the agent actually typed. + const command = canonicalizeCommand(rawCommand); + const isAliased = command !== rawCommand; + // ─── Recursion guard: reject nested chains ────────────────── if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) { return { status: 400, result: JSON.stringify({ error: 'Nested chain commands are not allowed' }), json: true }; @@ -1090,10 +1099,13 @@ async function handleCommandInternal( const helpText = generateHelpText(); return { status: 200, result: helpText }; } else { + // Use the rich unknown-command helper: names the input, suggests the closest + // match via Levenshtein (≤ 2 distance, ≥ 4 chars input), and appends an upgrade + // hint if the command is listed in NEW_IN_VERSION. return { status: 400, json: true, result: JSON.stringify({ - error: `Unknown command: ${command}`, + error: buildUnknownCommandError(rawCommand, ALL_COMMANDS), hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, }), }; @@ -1148,6 +1160,7 @@ async function handleCommandInternal( writeAuditEntry({ ts: new Date().toISOString(), cmd: command, + aliasOf: isAliased ? rawCommand : undefined, args: args.join(' '), origin: browserManager.getCurrentUrl(), durationMs: successDuration, @@ -1192,6 +1205,7 @@ async function handleCommandInternal( writeAuditEntry({ ts: new Date().toISOString(), cmd: command, + aliasOf: isAliased ? rawCommand : undefined, args: args.join(' '), origin: browserManager.getCurrentUrl(), durationMs: errorDuration, diff --git a/browse/src/tab-session.ts b/browse/src/tab-session.ts index e5e8279a..73994268 100644 --- a/browse/src/tab-session.ts +++ b/browse/src/tab-session.ts @@ -24,6 +24,8 @@ export interface RefEntry { name: string; } +export type SetContentWaitUntil = 'load' | 'domcontentloaded' | 'networkidle'; + export class TabSession { readonly page: Page; @@ -37,6 +39,30 @@ export class TabSession { // ─── Frame context ───────────────────────────────────────── private activeFrame: Frame | null = null; + // ─── Loaded HTML (for load-html replay across context recreation) ─ + // + // loadedHtml lifecycle: + // + // load-html cmd ──▶ session.setTabContent(html, opts) + // ├─▶ page.setContent(html, opts) + // └─▶ this.loadedHtml = html + // this.loadedHtmlWaitUntil = opts.waitUntil + // + // goto/back/forward/reload ──▶ session.clearLoadedHtml() + // (BEFORE Playwright call, so timeouts + // don't leave stale state) + // + // viewport --scale ──▶ recreateContext() + // ├─▶ saveState() captures { url, loadedHtml } per tab + // │ (in-memory only, never to disk) + // └─▶ restoreState(): + // for each tab with loadedHtml: + // newSession.setTabContent(html, opts) + // (NOT page.setContent — must rehydrate + // TabSession.loadedHtml too) + private loadedHtml: string | null = null; + private loadedHtmlWaitUntil: SetContentWaitUntil | undefined; + constructor(page: Page) { this.page = page; } @@ -131,10 +157,47 @@ export class TabSession { } /** - * Called on main-frame navigation to clear stale refs and frame context. + * Called on main-frame navigation to clear stale refs, frame context, and any + * load-html replay metadata. Runs for every main-frame nav — explicit goto/back/ + * forward/reload AND browser-emitted navigations (link clicks, form submits, JS + * redirects, OAuth). Without clearing loadedHtml here, a user who load-html'd and + * then clicked a link would silently revert to the original HTML on the next + * viewport --scale. */ onMainFrameNavigated(): void { this.clearRefs(); this.activeFrame = null; + this.loadedHtml = null; + this.loadedHtmlWaitUntil = undefined; + } + + // ─── Loaded HTML (load-html replay) ─────────────────────── + + /** + * Load HTML content into the tab AND store it for replay after context recreation + * (e.g. viewport --scale). Unlike page.setContent() alone, this rehydrates + * TabSession.loadedHtml so the next saveState()/restoreState() round-trip preserves + * the content. + */ + async setTabContent(html: string, opts: { waitUntil?: SetContentWaitUntil } = {}): Promise { + const waitUntil = opts.waitUntil ?? 'domcontentloaded'; + // Call setContent FIRST — only record the replay metadata after a successful load. + // If setContent throws (timeout, crash), we must not leave phantom HTML that a + // later viewport --scale would replay. + await this.page.setContent(html, { waitUntil, timeout: 15000 }); + this.loadedHtml = html; + this.loadedHtmlWaitUntil = waitUntil; + } + + /** Get stored HTML + waitUntil for state replay. Returns null if no load-html happened. */ + getLoadedHtml(): { html: string; waitUntil?: SetContentWaitUntil } | null { + if (this.loadedHtml === null) return null; + return { html: this.loadedHtml, waitUntil: this.loadedHtmlWaitUntil }; + } + + /** Clear stored HTML. Called BEFORE goto/back/forward/reload navigation. */ + clearLoadedHtml(): void { + this.loadedHtml = null; + this.loadedHtmlWaitUntil = undefined; } } diff --git a/browse/src/token-registry.ts b/browse/src/token-registry.ts index 56d3234d..455391eb 100644 --- a/browse/src/token-registry.ts +++ b/browse/src/token-registry.ts @@ -46,6 +46,7 @@ export const SCOPE_READ = new Set([ /** Commands that modify page state or navigate */ export const SCOPE_WRITE = new Set([ 'goto', 'back', 'forward', 'reload', + 'load-html', 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload', 'viewport', 'newtab', 'closetab', 'dialog-accept', 'dialog-dismiss', diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts index ddac0d5a..a619f182 100644 --- a/browse/src/url-validation.ts +++ b/browse/src/url-validation.ts @@ -3,6 +3,11 @@ * Localhost and private IPs are allowed (primary use case: QA testing local dev servers). */ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { validateReadPath } from './path-security'; + export const BLOCKED_METADATA_HOSTS = new Set([ '169.254.169.254', // AWS/GCP/Azure instance metadata 'fe80::1', // IPv6 link-local — common metadata endpoint alias @@ -105,17 +110,169 @@ async function resolvesToBlockedIp(hostname: string): Promise { } } -export async function validateNavigationUrl(url: string): Promise { +/** + * Normalize non-standard file:// URLs into absolute form before the WHATWG URL parser + * sees them. Handles cwd-relative, home-relative, and bare-segment shapes that the + * standard parser would otherwise mis-interpret as hostnames. + * + * file:///abs/path.html → unchanged + * file://./ → file:/// + * file://~/ → file:/// + * file:///... → file:////... (cwd-relative) + * file://localhost/ → unchanged + * file:///... → unchanged (caller rejects via host heuristic) + * + * Rejects empty (file://) and root-only (file:///) URLs — these would silently + * trigger Chromium's directory listing, which is a different product surface. + */ +export function normalizeFileUrl(url: string): string { + if (!url.toLowerCase().startsWith('file:')) return url; + + // Split off query + fragment BEFORE touching the path — SPAs + fixture URLs rely + // on these. path.resolve would URL-encode `?` and `#` as `%3F`/`%23` (and + // pathToFileURL drops them entirely), silently routing preview URLs to the + // wrong fixture. Extract, normalize the path, reattach at the end. + // + // Parse order: `?` before `#` per RFC 3986 — '?' in a fragment is literal. + // Find the FIRST `?` or `#`, whichever comes first, and take everything + // after (including the delimiter) as the trailing segment. + const qIdx = url.indexOf('?'); + const hIdx = url.indexOf('#'); + let delimIdx = -1; + if (qIdx >= 0 && hIdx >= 0) delimIdx = Math.min(qIdx, hIdx); + else if (qIdx >= 0) delimIdx = qIdx; + else if (hIdx >= 0) delimIdx = hIdx; + + const pathPart = delimIdx >= 0 ? url.slice(0, delimIdx) : url; + const trailing = delimIdx >= 0 ? url.slice(delimIdx) : ''; + + const rest = pathPart.slice('file:'.length); + + // file:/// or longer → standard absolute; pass through unchanged (caller validates path). + if (rest.startsWith('///')) { + // Reject bare root-only (file:/// with nothing after) + if (rest === '///' || rest === '////') { + throw new Error('Invalid file URL: file:/// has no path. Use file:///.'); + } + return pathPart + trailing; + } + + // Everything else: must start with // (we accept file://... only) + if (!rest.startsWith('//')) { + throw new Error(`Invalid file URL: ${url}. Use file:/// or file://./ or file://~/.`); + } + + const afterDoubleSlash = rest.slice(2); + + // Reject empty (file://) and trailing-slash-only (file://./ listing cwd). + if (afterDoubleSlash === '') { + throw new Error('Invalid file URL: file:// is empty. Use file:///.'); + } + if (afterDoubleSlash === '.' || afterDoubleSlash === './') { + throw new Error('Invalid file URL: file://./ would list the current directory. Use file://./ to render a specific file.'); + } + if (afterDoubleSlash === '~' || afterDoubleSlash === '~/') { + throw new Error('Invalid file URL: file://~/ would list the home directory. Use file://~/ to render a specific file.'); + } + + // Home-relative: file://~/ + if (afterDoubleSlash.startsWith('~/')) { + const rel = afterDoubleSlash.slice(2); + const absPath = path.join(os.homedir(), rel); + return pathToFileURL(absPath).href + trailing; + } + + // cwd-relative with explicit ./ : file://./ + if (afterDoubleSlash.startsWith('./')) { + const rel = afterDoubleSlash.slice(2); + const absPath = path.resolve(process.cwd(), rel); + return pathToFileURL(absPath).href + trailing; + } + + // localhost host explicitly allowed: file://localhost/ (pass through to standard parser). + if (afterDoubleSlash.toLowerCase().startsWith('localhost/')) { + return pathPart + trailing; + } + + // Ambiguous: file:/// — treat as cwd-relative ONLY if is a + // simple path name (no dots, no colons, no backslashes, no percent-encoding, no + // IPv6 brackets, no Windows drive letter pattern). + const firstSlash = afterDoubleSlash.indexOf('/'); + const segment = firstSlash === -1 ? afterDoubleSlash : afterDoubleSlash.slice(0, firstSlash); + + // Reject host-like segments: dotted names (docs.v1), IPs (127.0.0.1), IPv6 ([::1]), + // drive letters (C:), percent-encoded, or backslash paths. + const looksLikeHost = /[.:\\%]/.test(segment) || segment.startsWith('['); + if (looksLikeHost) { + throw new Error( + `Unsupported file URL host: ${segment}. Use file:/// for local files (network/UNC paths are not supported).` + ); + } + + // Simple-segment cwd-relative: file://docs/page.html → cwd/docs/page.html + const absPath = path.resolve(process.cwd(), afterDoubleSlash); + return pathToFileURL(absPath).href + trailing; +} + +/** + * Validate a navigation URL and return a normalized version suitable for page.goto(). + * + * Callers MUST use the return value — normalization of non-standard file:// forms + * only takes effect at the navigation site, not at the original URL. + * + * Callers (keep this list current, grep before removing): + * - write-commands.ts:goto + * - meta-commands.ts:diff (both URL args) + * - browser-manager.ts:newTab + * - browser-manager.ts:restoreState + */ +export async function validateNavigationUrl(url: string): Promise { + // Normalize non-standard file:// shapes before the URL parser sees them. + let normalized = url; + if (url.toLowerCase().startsWith('file:')) { + normalized = normalizeFileUrl(url); + } + let parsed: URL; try { - parsed = new URL(url); + parsed = new URL(normalized); } catch { throw new Error(`Invalid URL: ${url}`); } + // file:// path: validate against safe-dirs and allow; otherwise defer to http(s) logic. + if (parsed.protocol === 'file:') { + // Reject non-empty non-localhost hosts (UNC / network paths). + if (parsed.host !== '' && parsed.host.toLowerCase() !== 'localhost') { + throw new Error( + `Unsupported file URL host: ${parsed.host}. Use file:/// for local files.` + ); + } + + // Convert URL → filesystem path with proper decoding (handles %20, %2F, etc.) + // fileURLToPath strips query + hash; we reattach them after validation so SPA + // fixture URLs like file:///tmp/app.html?route=home#login survive intact. + let fsPath: string; + try { + fsPath = fileURLToPath(parsed); + } catch (e: any) { + throw new Error(`Invalid file URL: ${url} (${e.message})`); + } + + // Reject path traversal after decoding — e.g. file:///tmp/safe%2F..%2Fetc/passwd + // Note: fileURLToPath doesn't collapse .., so a literal '..' in the decoded path + // is suspicious. path.resolve will normalize it; check the result against safe dirs. + validateReadPath(fsPath); + + // Return the canonical file:// URL derived from the filesystem path + original + // query + hash. This guarantees page.goto() gets a well-formed URL regardless + // of input shape while preserving SPA route/query params. + return pathToFileURL(fsPath).href + parsed.search + parsed.hash; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error( - `Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.` + `Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, and file: URLs are permitted.` ); } @@ -137,4 +294,6 @@ export async function validateNavigationUrl(url: string): Promise { `Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.` ); } + + return url; } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 8dbb16f7..d925ac08 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -10,9 +10,10 @@ import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser'; import { generatePickerCode } from './cookie-picker-routes'; import { validateNavigationUrl } from './url-validation'; -import { validateOutputPath } from './path-security'; +import { validateOutputPath, validateReadPath } from './path-security'; import * as fs from 'fs'; import * as path from 'path'; +import type { SetContentWaitUntil } from './tab-session'; import { TEMP_DIR, isPathWithin } from './platform'; import { SAFE_DIRECTORIES } from './path-security'; import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector'; @@ -142,30 +143,129 @@ export async function handleWriteCommand( if (inFrame) throw new Error('Cannot use goto inside a frame. Run \'frame main\' first.'); const url = args[0]; if (!url) throw new Error('Usage: browse goto '); - await validateNavigationUrl(url); - const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + // Clear loadedHtml BEFORE navigation — a timeout after the main-frame commit + // must not leave stale content that could resurrect on a later context recreation. + session.clearLoadedHtml(); + const normalizedUrl = await validateNavigationUrl(url); + const response = await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); const status = response?.status() || 'unknown'; - return `Navigated to ${url} (${status})`; + return `Navigated to ${normalizedUrl} (${status})`; } case 'back': { if (inFrame) throw new Error('Cannot use back inside a frame. Run \'frame main\' first.'); + session.clearLoadedHtml(); await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Back → ${page.url()}`; } case 'forward': { if (inFrame) throw new Error('Cannot use forward inside a frame. Run \'frame main\' first.'); + session.clearLoadedHtml(); await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Forward → ${page.url()}`; } case 'reload': { if (inFrame) throw new Error('Cannot use reload inside a frame. Run \'frame main\' first.'); + session.clearLoadedHtml(); await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Reloaded ${page.url()}`; } + case 'load-html': { + if (inFrame) throw new Error('Cannot use load-html inside a frame. Run \'frame main\' first.'); + const filePath = args[0]; + if (!filePath) throw new Error('Usage: browse load-html [--wait-until load|domcontentloaded|networkidle]'); + + // Parse --wait-until flag + let waitUntil: SetContentWaitUntil = 'domcontentloaded'; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--wait-until') { + const val = args[++i]; + if (val !== 'load' && val !== 'domcontentloaded' && val !== 'networkidle') { + throw new Error(`Invalid --wait-until '${val}'. Must be one of: load, domcontentloaded, networkidle.`); + } + waitUntil = val; + } else if (args[i].startsWith('--')) { + throw new Error(`Unknown flag: ${args[i]}`); + } + } + + // Extension allowlist + const ALLOWED_EXT = ['.html', '.htm', '.xhtml', '.svg']; + const ext = path.extname(filePath).toLowerCase(); + if (!ALLOWED_EXT.includes(ext)) { + throw new Error( + `load-html: file does not appear to be HTML. Expected .html/.htm/.xhtml/.svg, got ${ext || '(no extension)'}. Rename the file if it's really HTML.` + ); + } + + const absolutePath = path.resolve(filePath); + + // Safe-dirs check (reuses canonical read-side policy) + try { + validateReadPath(absolutePath); + } catch (e: any) { + throw new Error( + `load-html: ${absolutePath} must be under ${SAFE_DIRECTORIES.join(' or ')} (security policy). Copy the file into the project tree or /tmp first.` + ); + } + + // stat check — reject non-file targets with actionable error + let stat: fs.Stats; + try { + stat = await fs.promises.stat(absolutePath); + } catch (e: any) { + if (e.code === 'ENOENT') { + throw new Error( + `load-html: file not found at ${absolutePath}. Check spelling or copy the file under ${process.cwd()} or ${TEMP_DIR}.` + ); + } + throw e; + } + if (stat.isDirectory()) { + throw new Error(`load-html: ${absolutePath} is a directory, not a file. Pass a .html file.`); + } + if (!stat.isFile()) { + throw new Error(`load-html: ${absolutePath} is not a regular file.`); + } + + // Size cap + const MAX_BYTES = parseInt(process.env.GSTACK_BROWSE_MAX_HTML_BYTES || '', 10) || (50 * 1024 * 1024); + if (stat.size > MAX_BYTES) { + throw new Error( + `load-html: file too large (${stat.size} bytes > ${MAX_BYTES} cap). Raise with GSTACK_BROWSE_MAX_HTML_BYTES= or split the HTML.` + ); + } + + // Single read: Buffer → magic-byte peek → utf-8 string + const buf = await fs.promises.readFile(absolutePath); + + // Magic-byte check: strip UTF-8 BOM + leading whitespace, then verify the first + // non-whitespace byte starts a markup construct. Accepts any ...` + // which setContent wraps in a full document. Rejects binary files mis-renamed .html + // (first byte won't be `<`). + let peek = buf.slice(0, 200); + if (peek[0] === 0xEF && peek[1] === 0xBB && peek[2] === 0xBF) { + peek = peek.slice(3); + } + const peekStr = peek.toString('utf8').trimStart(); + // Valid markup opener: '<' followed by alpha (tag), '!' (doctype/comment), or '?' (xml prolog) + const looksLikeMarkup = /^<[a-zA-Z!?]/.test(peekStr); + if (!looksLikeMarkup) { + const hexDump = Array.from(buf.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join(' '); + throw new Error( + `load-html: ${absolutePath} has ${ext} extension but content does not look like HTML. First bytes: ${hexDump}` + ); + } + + const html = buf.toString('utf8'); + await session.setTabContent(html, { waitUntil }); + return `Loaded HTML: ${absolutePath} (${stat.size} bytes)`; + } + case 'click': { const selector = args[0]; if (!selector) throw new Error('Usage: browse click '); @@ -343,11 +443,55 @@ export async function handleWriteCommand( } case 'viewport': { - const size = args[0]; - if (!size || !size.includes('x')) throw new Error('Usage: browse viewport (e.g., 375x812)'); - const [rawW, rawH] = size.split('x').map(Number); - const w = Math.min(Math.max(Math.round(rawW) || 1280, 1), 16384); - const h = Math.min(Math.max(Math.round(rawH) || 720, 1), 16384); + // Parse args: [] [--scale ]. Either may be omitted, but NOT both. + let sizeArg: string | undefined; + let scaleArg: number | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--scale') { + const val = args[++i]; + if (val === undefined || val === '') { + throw new Error('viewport --scale: missing value. Usage: viewport [WxH] --scale '); + } + const parsed = Number(val); + if (!Number.isFinite(parsed)) { + throw new Error(`viewport --scale: value '${val}' is not a finite number.`); + } + scaleArg = parsed; + } else if (args[i].startsWith('--')) { + throw new Error(`Unknown viewport flag: ${args[i]}`); + } else if (sizeArg === undefined) { + sizeArg = args[i]; + } else { + throw new Error(`Unexpected positional arg: ${args[i]}. Usage: viewport [WxH] [--scale ]`); + } + } + + if (sizeArg === undefined && scaleArg === undefined) { + throw new Error('Usage: browse viewport [] [--scale ] (e.g. 375x812, or --scale 2 to keep current size)'); + } + + // Resolve width/height: either from sizeArg or from current viewport if --scale-only. + let w: number, h: number; + if (sizeArg) { + if (!sizeArg.includes('x')) throw new Error('Usage: browse viewport [] [--scale ] (e.g., 375x812)'); + const [rawW, rawH] = sizeArg.split('x').map(Number); + w = Math.min(Math.max(Math.round(rawW) || 1280, 1), 16384); + h = Math.min(Math.max(Math.round(rawH) || 720, 1), 16384); + } else { + // --scale without WxH → use BrowserManager's tracked viewport (source of truth + // since setViewport + launchContext keep it in sync). Falls back reliably on + // headed → headless transitions or contexts with viewport:null. + const current = bm.getCurrentViewport(); + w = current.width; + h = current.height; + } + + if (scaleArg !== undefined) { + const err = await bm.setDeviceScaleFactor(scaleArg, w, h); + if (err) return `Viewport partially set: ${err}`; + return `Viewport set to ${w}x${h} @ ${scaleArg}x (context recreated; refs and load-html content replayed)`; + } + await bm.setViewport(w, h); return `Viewport set to ${w}x${h}`; } diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 2c006955..b3870c0c 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -2088,3 +2088,340 @@ describe('Frame', () => { await handleMetaCommand('frame', ['main'], bm, async () => {}); }); }); + +// ─── load-html ───────────────────────────────────────────────── + +describe('load-html', () => { + const tmpDir = '/tmp'; + const fixturePath = path.join(tmpDir, `browse-test-loadhtml-${Date.now()}.html`); + const fragmentPath = path.join(tmpDir, `browse-test-fragment-${Date.now()}.html`); + + beforeAll(() => { + fs.writeFileSync(fixturePath, '

loaded by load-html

'); + fs.writeFileSync(fragmentPath, '
fragment
'); + }); + + afterAll(() => { + try { fs.unlinkSync(fixturePath); } catch {} + try { fs.unlinkSync(fragmentPath); } catch {} + }); + + test('load-html loads HTML file into page', async () => { + const result = await handleWriteCommand('load-html', [fixturePath], bm); + expect(result).toContain('Loaded HTML:'); + expect(result).toContain(fixturePath); + const text = await handleReadCommand('text', [], bm); + expect(text).toContain('loaded by load-html'); + }); + + test('load-html accepts bare HTML fragments (no doctype)', async () => { + const result = await handleWriteCommand('load-html', [fragmentPath], bm); + expect(result).toContain('Loaded HTML:'); + const html = await handleReadCommand('html', [], bm); + expect(html).toContain('fragment'); + }); + + test('load-html rejects missing file arg', async () => { + try { + await handleWriteCommand('load-html', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/Usage: browse load-html/); + } + }); + + test('load-html rejects non-.html extension', async () => { + const txtPath = path.join(tmpDir, `load-html-test-${Date.now()}.txt`); + fs.writeFileSync(txtPath, ''); + try { + await handleWriteCommand('load-html', [txtPath], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/does not appear to be HTML/); + } finally { + try { fs.unlinkSync(txtPath); } catch {} + } + }); + + test('load-html rejects file outside safe dirs', async () => { + try { + await handleWriteCommand('load-html', ['/etc/passwd.html'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/must be under|not found|security policy/); + } + }); + + test('load-html rejects missing file with actionable error', async () => { + try { + await handleWriteCommand('load-html', [path.join(tmpDir, 'does-not-exist.html')], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/not found|security policy/); + } + }); + + test('load-html rejects directory target', async () => { + try { + await handleWriteCommand('load-html', [path.join(tmpDir, 'browse-test-notafile.html') + '/'], bm); + expect(true).toBe(false); + } catch (err: any) { + // Either "not found" or "is a directory" — both valid rejections + expect(err.message).toMatch(/not found|directory|not a regular file|security policy/); + } + }); + + test('load-html rejects binary content disguised as .html', async () => { + const binPath = path.join(tmpDir, `load-html-binary-${Date.now()}.html`); + // PNG magic bytes: 0x89 0x50 0x4E 0x47 + fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])); + try { + await handleWriteCommand('load-html', [binPath], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/does not look like HTML/); + } finally { + try { fs.unlinkSync(binPath); } catch {} + } + }); + + test('load-html strips UTF-8 BOM before magic-byte check', async () => { + const bomPath = path.join(tmpDir, `load-html-bom-${Date.now()}.html`); + const bomBytes = Buffer.from([0xEF, 0xBB, 0xBF]); + fs.writeFileSync(bomPath, Buffer.concat([bomBytes, Buffer.from('bom ok')])); + try { + const result = await handleWriteCommand('load-html', [bomPath], bm); + expect(result).toContain('Loaded HTML:'); + } finally { + try { fs.unlinkSync(bomPath); } catch {} + } + }); + + test('load-html --wait-until networkidle exercises non-default branch', async () => { + const result = await handleWriteCommand('load-html', [fixturePath, '--wait-until', 'networkidle'], bm); + expect(result).toContain('Loaded HTML:'); + }); + + test('load-html rejects invalid --wait-until value', async () => { + try { + await handleWriteCommand('load-html', [fixturePath, '--wait-until', 'bogus'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/Invalid --wait-until/); + } + }); + + test('load-html rejects unknown flag', async () => { + try { + await handleWriteCommand('load-html', [fixturePath, '--bogus'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/Unknown flag/); + } + }); +}); + +// ─── screenshot --selector ───────────────────────────────────── + +describe('screenshot --selector', () => { + test('--selector flag with output path captures element', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const p = `/tmp/browse-test-selector-${Date.now()}.png`; + const result = await handleMetaCommand('screenshot', ['--selector', '#title', p], bm, async () => {}); + expect(result).toContain('Screenshot saved (element)'); + expect(fs.existsSync(p)).toBe(true); + fs.unlinkSync(p); + }); + + test('--selector conflicts with positional selector', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('screenshot', ['--selector', '#title', '.other'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/conflicts with positional selector/); + } + }); + + test('--selector conflicts with --clip', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('screenshot', ['--selector', '#title', '--clip', '0,0,100,100'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/Cannot use --clip with a selector/); + } + }); + + test('--selector with --base64 returns element base64', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleMetaCommand('screenshot', ['--selector', '#title', '--base64'], bm, async () => {}); + expect(result).toMatch(/^data:image\/png;base64,/); + }); + + test('--selector missing value throws', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('screenshot', ['--selector'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/Usage: screenshot --selector/); + } + }); +}); + +// ─── viewport --scale ─────────────────────────────────────────── + +describe('viewport --scale', () => { + test('viewport WxH --scale 2 produces 2x dimension screenshot', async () => { + const tmpFix = path.join('/tmp', `scale-${Date.now()}.html`); + fs.writeFileSync(tmpFix, '
'); + try { + await handleWriteCommand('viewport', ['200x200', '--scale', '2'], bm); + await handleWriteCommand('load-html', [tmpFix], bm); + const p = `/tmp/scale-${Date.now()}.png`; + await handleMetaCommand('screenshot', ['--selector', '#box', p], bm, async () => {}); + // Parse PNG IHDR (bytes 16-23 are width/height big-endian u32) + const buf = fs.readFileSync(p); + const w = buf.readUInt32BE(16); + const h = buf.readUInt32BE(20); + // Box is 100x50 at 2x = 200x100 + expect(w).toBe(200); + expect(h).toBe(100); + fs.unlinkSync(p); + // Reset scale for other tests + await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm); + } finally { + try { fs.unlinkSync(tmpFix); } catch {} + } + }); + + test('viewport --scale without WxH keeps current size', async () => { + await handleWriteCommand('viewport', ['800x600'], bm); + const result = await handleWriteCommand('viewport', ['--scale', '2'], bm); + expect(result).toContain('800x600'); + expect(result).toContain('2x'); + expect(bm.getDeviceScaleFactor()).toBe(2); + await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm); + }); + + test('--scale non-finite (NaN) throws', async () => { + try { + await handleWriteCommand('viewport', ['100x100', '--scale', 'abc'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/not a finite number/); + } + }); + + test('--scale out of range throws', async () => { + try { + await handleWriteCommand('viewport', ['100x100', '--scale', '4'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/between 1 and 3/); + } + try { + await handleWriteCommand('viewport', ['100x100', '--scale', '0.5'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/between 1 and 3/); + } + }); + + test('--scale missing value throws', async () => { + try { + await handleWriteCommand('viewport', ['--scale'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/missing value/); + } + }); + + test('viewport with neither arg nor flag throws usage', async () => { + try { + await handleWriteCommand('viewport', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toMatch(/Usage: browse viewport/); + } + }); +}); + +// ─── setContent replay across context recreation ──────────────── + +describe('setContent replay (load-html survives viewport --scale)', () => { + const tmpDir = '/tmp'; + + test('load-html → viewport --scale 2 → content survives', async () => { + const fix = path.join(tmpDir, `replay-${Date.now()}.html`); + fs.writeFileSync(fix, '

replay-test-marker

'); + try { + await handleWriteCommand('load-html', [fix], bm); + await handleWriteCommand('viewport', ['400x300', '--scale', '2'], bm); + const text = await handleReadCommand('text', [], bm); + expect(text).toContain('replay-test-marker'); + await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm); + } finally { + try { fs.unlinkSync(fix); } catch {} + } + }); + + test('double scale cycle: 2x → 1.5x, content still survives', async () => { + const fix = path.join(tmpDir, `replay2-${Date.now()}.html`); + fs.writeFileSync(fix, '

double-cycle-marker

'); + try { + await handleWriteCommand('load-html', [fix], bm); + await handleWriteCommand('viewport', ['400x300', '--scale', '2'], bm); + await handleWriteCommand('viewport', ['400x300', '--scale', '1.5'], bm); + const text = await handleReadCommand('text', [], bm); + expect(text).toContain('double-cycle-marker'); + await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm); + } finally { + try { fs.unlinkSync(fix); } catch {} + } + }); + + test('goto clears loadedHtml — subsequent viewport --scale does NOT resurrect old HTML', async () => { + const fix = path.join(tmpDir, `clear-${Date.now()}.html`); + fs.writeFileSync(fix, '
stale-content
'); + try { + await handleWriteCommand('load-html', [fix], bm); + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + await handleWriteCommand('viewport', ['400x300', '--scale', '2'], bm); + const text = await handleReadCommand('text', [], bm); + // Should see basic.html content, NOT the stale load-html content + expect(text).not.toContain('stale-content'); + await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm); + } finally { + try { fs.unlinkSync(fix); } catch {} + } + }); +}); + +// ─── Alias routing ───────────────────────────────────────────── + +describe('Command aliases', () => { + const tmpDir = '/tmp'; + const aliasFix = path.join(tmpDir, `alias-${Date.now()}.html`); + + beforeAll(() => { + fs.writeFileSync(aliasFix, '

alias routing ok

'); + }); + afterAll(() => { + try { fs.unlinkSync(aliasFix); } catch {} + }); + + test('setcontent alias routes to load-html via chain', async () => { + // Chain canonicalizes aliases end-to-end; verifies the dispatch path + const result = await handleMetaCommand('chain', [JSON.stringify([['setcontent', aliasFix]])], bm, async () => {}); + expect(result).toContain('Loaded HTML:'); + const text = await handleReadCommand('text', [], bm); + expect(text).toContain('alias routing ok'); + }); + + test('set-content (hyphenated) alias also routes', async () => { + const result = await handleMetaCommand('chain', [JSON.stringify([['set-content', aliasFix]])], bm, async () => {}); + expect(result).toContain('Loaded HTML:'); + }); +}); diff --git a/browse/test/dx-polish.test.ts b/browse/test/dx-polish.test.ts new file mode 100644 index 00000000..800a422a --- /dev/null +++ b/browse/test/dx-polish.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'bun:test'; +import { + canonicalizeCommand, + COMMAND_ALIASES, + NEW_IN_VERSION, + buildUnknownCommandError, + ALL_COMMANDS, +} from '../src/commands'; + +describe('canonicalizeCommand', () => { + it('resolves setcontent → load-html', () => { + expect(canonicalizeCommand('setcontent')).toBe('load-html'); + }); + + it('resolves set-content → load-html', () => { + expect(canonicalizeCommand('set-content')).toBe('load-html'); + }); + + it('resolves setContent → load-html (case-sensitive key)', () => { + expect(canonicalizeCommand('setContent')).toBe('load-html'); + }); + + it('passes canonical names through unchanged', () => { + expect(canonicalizeCommand('load-html')).toBe('load-html'); + expect(canonicalizeCommand('goto')).toBe('goto'); + }); + + it('passes unknown names through unchanged (alias map is allowlist, not filter)', () => { + expect(canonicalizeCommand('totally-made-up')).toBe('totally-made-up'); + }); +}); + +describe('buildUnknownCommandError', () => { + it('names the input in every error', () => { + const msg = buildUnknownCommandError('xyz', ALL_COMMANDS); + expect(msg).toContain(`Unknown command: 'xyz'`); + }); + + it('suggests closest match within Levenshtein 2 when input length >= 4', () => { + const msg = buildUnknownCommandError('load-htm', ALL_COMMANDS); + expect(msg).toContain(`Did you mean 'load-html'?`); + }); + + it('does NOT suggest for short inputs (< 4 chars, avoids noise on js/is typos)', () => { + // 'j' is distance 1 from 'js' but only 1 char — suggestion would be noisy + const msg = buildUnknownCommandError('j', ALL_COMMANDS); + expect(msg).not.toContain('Did you mean'); + }); + + it('uses alphabetical tiebreak for deterministic suggestions', () => { + // Synthetic command set where two commands tie on distance from input + const syntheticSet = new Set(['alpha', 'beta']); + // 'alpha' vs 'delta' = 3 edits; 'beta' vs 'delta' = 2 edits + // Let's use a case that genuinely ties. + const ties = new Set(['abcd', 'abce']); // both distance 1 from 'abcf' + const msg = buildUnknownCommandError('abcf', ties, {}, {}); + // Alphabetical first: 'abcd' comes before 'abce' + expect(msg).toContain(`Did you mean 'abcd'?`); + }); + + it('appends upgrade hint when command appears in NEW_IN_VERSION', () => { + // Synthetic: pretend load-html isn't in the command set (agent on older build) + const noLoadHtml = new Set([...ALL_COMMANDS].filter(c => c !== 'load-html')); + const msg = buildUnknownCommandError('load-html', noLoadHtml, COMMAND_ALIASES, NEW_IN_VERSION); + expect(msg).toContain('added in browse v'); + expect(msg).toContain('Upgrade:'); + }); + + it('omits upgrade hint for unknown commands not in NEW_IN_VERSION', () => { + const msg = buildUnknownCommandError('notarealcommand', ALL_COMMANDS); + expect(msg).not.toContain('added in browse v'); + }); + + it('NEW_IN_VERSION has load-html entry', () => { + expect(NEW_IN_VERSION['load-html']).toBeTruthy(); + }); + + it('COMMAND_ALIASES + command set are consistent — all alias targets exist', () => { + for (const target of Object.values(COMMAND_ALIASES)) { + expect(ALL_COMMANDS.has(target)).toBe(true); + } + }); +}); + +describe('Alias + SCOPE_WRITE integration invariant', () => { + it('load-html is in SCOPE_WRITE (alias canonicalization happens before scope check)', async () => { + const { SCOPE_WRITE } = await import('../src/token-registry'); + expect(SCOPE_WRITE.has('load-html')).toBe(true); + }); + + it('setcontent is NOT directly in any scope set (must canonicalize first)', async () => { + const { SCOPE_WRITE, SCOPE_READ, SCOPE_ADMIN, SCOPE_CONTROL } = await import('../src/token-registry'); + // The alias itself must NOT appear in any scope set — only the canonical form. + // This proves scope enforcement relies on canonicalization at dispatch time, + // not on the alias leaking through as an acceptable command. + expect(SCOPE_WRITE.has('setcontent')).toBe(false); + expect(SCOPE_READ.has('setcontent')).toBe(false); + expect(SCOPE_ADMIN.has('setcontent')).toBe(false); + expect(SCOPE_CONTROL.has('setcontent')).toBe(false); + }); +}); diff --git a/browse/test/security-audit-r2.test.ts b/browse/test/security-audit-r2.test.ts index 985a53ed..97e9f082 100644 --- a/browse/test/security-audit-r2.test.ts +++ b/browse/test/security-audit-r2.test.ts @@ -392,12 +392,13 @@ describe('frame --url ReDoS fix', () => { describe('chain command watch-mode guard', () => { it('chain loop contains isWatching() guard before write dispatch', () => { - const block = sliceBetween(META_SRC, 'for (const cmd of commands)', 'Wait for network to settle'); + // Post-alias refactor: loop iterates over canonicalized `c of commands`. + const block = sliceBetween(META_SRC, 'for (const c of commands)', 'Wait for network to settle'); expect(block).toContain('isWatching'); }); it('chain loop BLOCKED message appears for write commands in watch mode', () => { - const block = sliceBetween(META_SRC, 'for (const cmd of commands)', 'Wait for network to settle'); + const block = sliceBetween(META_SRC, 'for (const c of commands)', 'Wait for network to settle'); expect(block).toContain('BLOCKED: write commands disabled in watch mode'); }); }); diff --git a/browse/test/url-validation.test.ts b/browse/test/url-validation.test.ts index f6e52175..cdeb2b05 100644 --- a/browse/test/url-validation.test.ts +++ b/browse/test/url-validation.test.ts @@ -1,29 +1,50 @@ import { describe, it, expect } from 'bun:test'; -import { validateNavigationUrl } from '../src/url-validation'; +import { validateNavigationUrl, normalizeFileUrl } from '../src/url-validation'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR } from '../src/platform'; describe('validateNavigationUrl', () => { it('allows http URLs', async () => { - await expect(validateNavigationUrl('http://example.com')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('http://example.com')).resolves.toBe('http://example.com'); }); it('allows https URLs', async () => { - await expect(validateNavigationUrl('https://example.com/path?q=1')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('https://example.com/path?q=1')).resolves.toBe('https://example.com/path?q=1'); }); it('allows localhost', async () => { - await expect(validateNavigationUrl('http://localhost:3000')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('http://localhost:3000')).resolves.toBe('http://localhost:3000'); }); it('allows 127.0.0.1', async () => { - await expect(validateNavigationUrl('http://127.0.0.1:8080')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('http://127.0.0.1:8080')).resolves.toBe('http://127.0.0.1:8080'); }); it('allows private IPs', async () => { - await expect(validateNavigationUrl('http://192.168.1.1')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('http://192.168.1.1')).resolves.toBe('http://192.168.1.1'); }); - it('blocks file:// scheme', async () => { - await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/scheme.*not allowed/i); + it('rejects file:// paths outside safe dirs (cwd + TEMP_DIR)', async () => { + // file:// is accepted as a scheme now, but safe-dirs policy blocks /etc/passwd. + await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/Path must be within/i); + }); + + it('accepts file:// for files under TEMP_DIR', async () => { + const tmpHtml = path.join(TEMP_DIR, `browse-test-${Date.now()}.html`); + fs.writeFileSync(tmpHtml, 'ok'); + try { + const result = await validateNavigationUrl(`file://${tmpHtml}`); + // Result should be a canonical file:// URL (pathToFileURL form) + expect(result.startsWith('file://')).toBe(true); + expect(result.toLowerCase()).toContain('browse-test-'); + } finally { + fs.unlinkSync(tmpHtml); + } + }); + + it('rejects unsupported file URL host (UNC/network paths)', async () => { + await expect(validateNavigationUrl('file://host.example.com/foo.html')).rejects.toThrow(/Unsupported file URL host/i); }); it('blocks javascript: scheme', async () => { @@ -79,11 +100,11 @@ describe('validateNavigationUrl', () => { }); it('does not block hostnames starting with fd (e.g. fd.example.com)', async () => { - await expect(validateNavigationUrl('https://fd.example.com/')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('https://fd.example.com/')).resolves.toBe('https://fd.example.com/'); }); it('does not block hostnames starting with fc (e.g. fcustomer.com)', async () => { - await expect(validateNavigationUrl('https://fcustomer.com/')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('https://fcustomer.com/')).resolves.toBe('https://fcustomer.com/'); }); it('throws on malformed URLs', async () => { @@ -92,8 +113,8 @@ describe('validateNavigationUrl', () => { }); describe('validateNavigationUrl — restoreState coverage', () => { - it('blocks file:// URLs that could appear in saved state', async () => { - await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/scheme.*not allowed/i); + it('blocks file:// URLs outside safe dirs that could appear in saved state', async () => { + await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/Path must be within/i); }); it('blocks chrome:// URLs that could appear in saved state', async () => { @@ -105,10 +126,98 @@ describe('validateNavigationUrl — restoreState coverage', () => { }); it('allows normal https URLs from saved state', async () => { - await expect(validateNavigationUrl('https://example.com/page')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('https://example.com/page')).resolves.toBe('https://example.com/page'); }); it('allows localhost URLs from saved state', async () => { - await expect(validateNavigationUrl('http://localhost:3000/app')).resolves.toBeUndefined(); + await expect(validateNavigationUrl('http://localhost:3000/app')).resolves.toBe('http://localhost:3000/app'); + }); +}); + +describe('normalizeFileUrl', () => { + const cwd = process.cwd(); + + it('passes through absolute file:/// URLs unchanged', () => { + expect(normalizeFileUrl('file:///tmp/page.html')).toBe('file:///tmp/page.html'); + }); + + it('expands file://./ to absolute file:///', () => { + const result = normalizeFileUrl('file://./docs/page.html'); + expect(result.startsWith('file://')).toBe(true); + expect(result).toContain(cwd.replace(/\\/g, '/')); + expect(result.endsWith('/docs/page.html')).toBe(true); + }); + + it('expands file://~/ to absolute file:///', () => { + const result = normalizeFileUrl('file://~/Documents/page.html'); + expect(result.startsWith('file://')).toBe(true); + expect(result.endsWith('/Documents/page.html')).toBe(true); + }); + + it('expands file:/// to cwd-relative', () => { + const result = normalizeFileUrl('file://docs/page.html'); + expect(result.startsWith('file://')).toBe(true); + expect(result).toContain(cwd.replace(/\\/g, '/')); + expect(result.endsWith('/docs/page.html')).toBe(true); + }); + + it('passes through file://localhost/ unchanged', () => { + expect(normalizeFileUrl('file://localhost/tmp/page.html')).toBe('file://localhost/tmp/page.html'); + }); + + it('rejects empty file:// URL', () => { + expect(() => normalizeFileUrl('file://')).toThrow(/is empty/i); + }); + + it('rejects file:/// with no path', () => { + expect(() => normalizeFileUrl('file:///')).toThrow(/no path/i); + }); + + it('rejects file://./ (directory listing)', () => { + expect(() => normalizeFileUrl('file://./')).toThrow(/current directory/i); + }); + + it('rejects dotted host-like segment file://docs.v1/page.html', () => { + expect(() => normalizeFileUrl('file://docs.v1/page.html')).toThrow(/Unsupported file URL host/i); + }); + + it('rejects IP-like host file://127.0.0.1/foo', () => { + expect(() => normalizeFileUrl('file://127.0.0.1/tmp/x')).toThrow(/Unsupported file URL host/i); + }); + + it('rejects IPv6 host file://[::1]/foo', () => { + expect(() => normalizeFileUrl('file://[::1]/tmp/x')).toThrow(/Unsupported file URL host/i); + }); + + it('rejects Windows drive letter file://C:/Users/x', () => { + expect(() => normalizeFileUrl('file://C:/Users/x')).toThrow(/Unsupported file URL host/i); + }); + + it('passes through non-file URLs', () => { + expect(normalizeFileUrl('https://example.com')).toBe('https://example.com'); + }); +}); + +describe('validateNavigationUrl — file:// URL-encoding', () => { + it('decodes %20 via fileURLToPath (space in filename)', async () => { + const tmpHtml = path.join(TEMP_DIR, `hello world ${Date.now()}.html`); + fs.writeFileSync(tmpHtml, 'ok'); + try { + // Build an escaped file:// URL and verify it validates against the actual path + const encodedPath = tmpHtml.split('/').map(encodeURIComponent).join('/'); + const url = `file://${encodedPath}`; + const result = await validateNavigationUrl(url); + expect(result.startsWith('file://')).toBe(true); + } finally { + fs.unlinkSync(tmpHtml); + } + }); + + it('rejects path traversal via encoded slash (file:///tmp/safe%2F..%2Fetc/passwd)', async () => { + // Node's fileURLToPath rejects encoded slashes outright with a clear error. + // Either "encoded /" rejection OR "Path must be within" safe-dirs rejection is acceptable. + await expect( + validateNavigationUrl('file:///tmp/safe%2F..%2Fetc/passwd') + ).rejects.toThrow(/encoded \/|Path must be within/i); }); }); diff --git a/package.json b/package.json index cfc1703c..732fcde1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.0.0.0", + "version": "1.1.0.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",