mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
Merge origin/main into garrytan/ship-version-sync
Main bumped to v1.1.0.0 (browse Puppeteer-parity wave: load-html, screenshot --selector, viewport --scale, file://). Resolved conflicts to ship /ship's drift fix on top as v1.1.1.0. Conflicts: - VERSION: 1.0.1.0 vs 1.1.0.0 -> 1.1.1.0 (PATCH on top of main's MINOR) - package.json "version": same resolution - CHANGELOG.md: kept main's v1.1.0.0 entry; renamed my v1.0.1.0 entry to v1.1.1.0 and placed it above main's entry (next to land) Dogfooded: new Step 12 classified the merged state correctly as ALREADY_BUMPED (BASE=1.1.0.0, VERSION=1.1.1.0, pkg=1.1.1.0 — all synced). Tests: bun run test green (14/14 ship-version-sync, 73/73 host-config, full free suite exit 0). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+39
-7
@@ -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 <sel> <prop> <val>`, `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 <css>] [--viewport] [--clip x,y,w,h] [--base64] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees |
|
||||
| Compare | `diff <url1> <url2>` | 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 <css> [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 <n>` 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://<abs-path>` | File already on disk | `file:///...` | Resolve against file's directory |
|
||||
| `goto file://./<rel>`, `goto file://~/<rel>`, `goto file://<seg>` | Smart-parsed to absolute | `file:///...` | Same |
|
||||
| `load-html <file>` | 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
|
||||
|
||||
|
||||
+28
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.1.0] - 2026-04-18
|
||||
## [1.1.1.0] - 2026-04-18
|
||||
|
||||
### Fixed
|
||||
- **`/ship` no longer silently lets `VERSION` and `package.json` drift.** Before this fix, `/ship`'s Step 12 read and bumped only the `VERSION` file. Any downstream consumer that reads `package.json` (registry UIs, `bun pm view`, `npm publish`, future helpers) would see a stale semver, and because the idempotency check keyed on `VERSION` alone, the next `/ship` run couldn't detect it had drifted. Now Step 12 classifies into four states — FRESH, ALREADY_BUMPED, DRIFT_STALE_PKG, DRIFT_UNEXPECTED — detects drift in every direction, repairs it via a sync-only path that can't double-bump, and halts loudly when `VERSION` and `package.json` disagree in an ambiguous way.
|
||||
@@ -10,6 +10,33 @@
|
||||
- New test file at `test/ship-version-sync.test.ts` — 14 cases covering every branch of the new Step 12 logic, including the critical no-double-bump path (drift-repair must never call the normal bump action), trailing-CR regression, and invalid-semver repair rejection.
|
||||
- Review history on this fix: one round of `/plan-eng-review`, one round of `/codex` plan review (found a double-bump bug in the original design), one round of Claude adversarial subagent (found CRLF handling gap and unvalidated `REPAIR_VERSION`). All surfaced issues applied in-branch.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -797,7 +797,8 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
|---------|-------------|
|
||||
| `back` | History back |
|
||||
| `forward` | History forward |
|
||||
| `goto <url>` | Navigate to URL |
|
||||
| `goto <url>` | Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) |
|
||||
| `load-html <file> [--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 <text>` | Type into focused element |
|
||||
| `upload <sel> <file> [file2...]` | Upload file(s) |
|
||||
| `useragent <string>` | Set user agent |
|
||||
| `viewport <WxH>` | Set viewport size |
|
||||
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
|
||||
| `wait <sel|--networkidle|--load>` | 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 <css>] [--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 |
|
||||
|
||||
+55
-3
@@ -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 '<div class="tweet">hello</div>' > /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 <url>` |
|
||||
| `await page.setContent(html)` | `$B load-html <file>` (or `$B goto file://<abs>`) |
|
||||
| `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 <path> --selector .x` |
|
||||
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
||||
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
||||
|
||||
Worked example (the tweet-renderer flow — Puppeteer → browse):
|
||||
|
||||
```bash
|
||||
# Generate HTML in memory, render at 2x scale, screenshot the tweet card.
|
||||
echo '<div class="tweet-card" style="width:400px;height:200px;background:#1da1f2;color:white;padding:20px">hello</div>' > /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 <url>` | Navigate to URL |
|
||||
| `goto <url>` | Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) |
|
||||
| `load-html <file> [--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 <text>` | Type into focused element |
|
||||
| `upload <sel> <file> [file2...]` | Upload file(s) |
|
||||
| `useragent <string>` | Set user agent |
|
||||
| `viewport <WxH>` | Set viewport size |
|
||||
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
|
||||
| `wait <sel|--networkidle|--load>` | 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 <css>] [--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 |
|
||||
|
||||
@@ -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 '<div class="tweet">hello</div>' > /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 <url>` |
|
||||
| `await page.setContent(html)` | `$B load-html <file>` (or `$B goto file://<abs>`) |
|
||||
| `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 <path> --selector .x` |
|
||||
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
||||
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
||||
|
||||
Worked example (the tweet-renderer flow — Puppeteer → browse):
|
||||
|
||||
```bash
|
||||
# Generate HTML in memory, render at 2x scale, screenshot the tweet card.
|
||||
echo '<div class="tweet-card" style="width:400px;height:200px;background:#1da1f2;color:white;padding:20px">hello</div>' > /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
|
||||
|
||||
@@ -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');
|
||||
|
||||
+132
-13
@@ -31,6 +31,18 @@ export interface BrowserState {
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
storage: { localStorage: Record<string, string>; sessionStorage: Record<string, string> } | 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<string, string> = {};
|
||||
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<number> {
|
||||
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://<segment> 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<string | null> {
|
||||
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.
|
||||
|
||||
+103
-3
@@ -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<string, { category: string; description: string; usage?: string }> = {
|
||||
// Navigation
|
||||
'goto': { category: 'Navigation', description: 'Navigate to URL', usage: 'goto <url>' },
|
||||
'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto <url>' },
|
||||
'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 <file> [--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<string, { category: string; descriptio
|
||||
'scroll': { category: 'Interaction', description: 'Scroll element into view, or scroll to page bottom if no selector', usage: 'scroll [sel]' },
|
||||
'wait': { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait <sel|--networkidle|--load>' },
|
||||
'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload <sel> <file> [file2...]' },
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport <WxH>' },
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'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<string, { category: string; descriptio
|
||||
'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape <images|videos|media> [--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 <css>] [--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 <url1> <url2>' },
|
||||
@@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<string>,
|
||||
aliasMap: Record<string, string> = COMMAND_ALIASES,
|
||||
newInVersion: Record<string, string> = 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;
|
||||
}
|
||||
|
||||
+60
-28
@@ -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 <css> [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 <url1> <url2>');
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
+18
-4
@@ -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<CommandResult> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateNavigationUrl(url: string): Promise<void> {
|
||||
/**
|
||||
* 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://./<rel> → file://<cwd>/<rel>
|
||||
* file://~/<rel> → file://<HOME>/<rel>
|
||||
* file://<single-segment>/... → file://<cwd>/<single-segment>/... (cwd-relative)
|
||||
* file://localhost/<abs> → unchanged
|
||||
* file://<host-like>/... → 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:///<absolute-path>.');
|
||||
}
|
||||
return pathPart + trailing;
|
||||
}
|
||||
|
||||
// Everything else: must start with // (we accept file://... only)
|
||||
if (!rest.startsWith('//')) {
|
||||
throw new Error(`Invalid file URL: ${url}. Use file:///<absolute-path> or file://./<rel> or file://~/<rel>.`);
|
||||
}
|
||||
|
||||
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:///<absolute-path>.');
|
||||
}
|
||||
if (afterDoubleSlash === '.' || afterDoubleSlash === './') {
|
||||
throw new Error('Invalid file URL: file://./ would list the current directory. Use file://./<filename> to render a specific file.');
|
||||
}
|
||||
if (afterDoubleSlash === '~' || afterDoubleSlash === '~/') {
|
||||
throw new Error('Invalid file URL: file://~/ would list the home directory. Use file://~/<filename> to render a specific file.');
|
||||
}
|
||||
|
||||
// Home-relative: file://~/<rel>
|
||||
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://./<rel>
|
||||
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/<abs> (pass through to standard parser).
|
||||
if (afterDoubleSlash.toLowerCase().startsWith('localhost/')) {
|
||||
return pathPart + trailing;
|
||||
}
|
||||
|
||||
// Ambiguous: file://<segment>/<rest> — treat as cwd-relative ONLY if <segment> 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:///<absolute-path> 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<string> {
|
||||
// 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:///<absolute-path> 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<void> {
|
||||
`Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -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 <url>');
|
||||
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 <file> [--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=<N> 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 <tag, <!doctype,
|
||||
// <!-- comment, <?xml prolog — including bare HTML fragments like `<div>...</div>`
|
||||
// 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 <selector>');
|
||||
@@ -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 <WxH> (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: [<WxH>] [--scale <n>]. 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 <n>');
|
||||
}
|
||||
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 <n>]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeArg === undefined && scaleArg === undefined) {
|
||||
throw new Error('Usage: browse viewport [<WxH>] [--scale <n>] (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 [<WxH>] [--scale <n>] (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}`;
|
||||
}
|
||||
|
||||
@@ -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, '<html><body><h1 id="loaded">loaded by load-html</h1></body></html>');
|
||||
fs.writeFileSync(fragmentPath, '<div class="fragment" style="width:100px;height:50px">fragment</div>');
|
||||
});
|
||||
|
||||
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, '<html></html>');
|
||||
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('<html><body>bom ok</body></html>')]));
|
||||
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, '<div id="box" style="width:100px;height:50px;background:#f00"></div>');
|
||||
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, '<h1 id="marker">replay-test-marker</h1>');
|
||||
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, '<h2 id="m">double-cycle-marker</h2>');
|
||||
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, '<div id="stale">stale-content</div>');
|
||||
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, '<p id="alias">alias routing ok</p>');
|
||||
});
|
||||
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:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '<html><body>ok</body></html>');
|
||||
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://./<rel> to absolute file://<cwd>/<rel>', () => {
|
||||
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://~/<rel> to absolute file://<homedir>/<rel>', () => {
|
||||
const result = normalizeFileUrl('file://~/Documents/page.html');
|
||||
expect(result.startsWith('file://')).toBe(true);
|
||||
expect(result.endsWith('/Documents/page.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('expands file://<simple-segment>/<rest> 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/<abs> 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, '<html>ok</html>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.0.1.0",
|
||||
"version": "1.1.1.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user