full native (#754)

* full native

* fix: apply cargo fmt formatting

* fix: prevent zip path traversal in Chromium installer

Use enclosed_name() to sanitize zip entry paths, preventing malicious
archives from writing outside the extraction directory.

* improvements

* fix: apply cargo fmt formatting

* benchmarks

* bench

* updates

* fixes
This commit is contained in:
Chris Tate
2026-03-13 19:59:21 -05:00
committed by GitHub
parent d4b948c1d4
commit 8e43469c8b
88 changed files with 2511 additions and 22629 deletions
+26 -105
View File
@@ -18,43 +18,6 @@ jobs:
- name: Check version sync
run: node scripts/check-version-sync.js
typescript:
name: TypeScript (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Typecheck
run: pnpm typecheck
- name: Format check
run: pnpm format:check
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run tests
run: pnpm test
rust:
name: Rust
runs-on: ubuntu-latest
@@ -112,6 +75,30 @@ jobs:
- name: Run Rust tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
native-e2e:
name: Native E2E Tests
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: rust
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: cli
- name: Install Chrome
run: |
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
- name: Run e2e tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
windows-integration:
name: Windows Integration Test
if: github.event_name != 'pull_request'
@@ -122,17 +109,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
@@ -146,12 +122,6 @@ jobs:
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory
run: |
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
@@ -169,18 +139,6 @@ jobs:
shell: pwsh
timeout-minutes: 10
- name: Verify Chromium was installed
run: |
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
if (Test-Path $playwrightPath) {
Write-Host "Playwright browsers installed at: $playwrightPath"
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
} else {
Write-Error "Playwright browsers not found!"
exit 1
}
shell: pwsh
- name: Test daemon lifecycle (open, snapshot, close)
run: |
$env:PATH = "$pwd\bin;$env:PATH"
@@ -198,37 +156,6 @@ jobs:
shell: pwsh
timeout-minutes: 5
serverless-chromium:
name: Serverless Chromium (@sparticuz/chromium)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Install @sparticuz/chromium
run: pnpm add -D @sparticuz/chromium
- name: Build TypeScript
run: pnpm build
- name: Run serverless integration test
run: pnpm exec vitest run test/serverless.test.ts
global-install:
name: Global Install (${{ matrix.os }})
if: github.event_name != 'pull_request'
@@ -275,12 +202,6 @@ jobs:
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory (Unix)
if: runner.os != 'Windows'
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
@@ -307,7 +228,7 @@ jobs:
echo "ERROR: Symlink should point to native binary, not JS wrapper"
exit 1
fi
echo "Symlink correctly points to native binary"
echo "Symlink correctly points to native binary"
shell: bash
- name: Verify shim points to native binary (Windows)
@@ -322,5 +243,5 @@ jobs:
echo "ERROR: Shim should point to native .exe, not JS wrapper"
exit 1
}
echo "Shim correctly points to native binary"
echo "Shim correctly points to native binary"
shell: pwsh
-1
View File
@@ -1,3 +1,2 @@
pnpm lint-staged
node scripts/sync-version.js
git add cli/Cargo.toml cli/Cargo.lock
+2 -18
View File
@@ -26,25 +26,9 @@ This applies to changes that either human users or AI agents would need to know
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
## Dual Architecture (Node.js + Native)
## Architecture
The codebase has two daemon implementations:
- **Node.js/Playwright** (default) -- `src/daemon.ts`, `src/actions.ts`, `src/browser.ts`, and the rest of `src/`
- **Rust/Native** (experimental, `--native` or `AGENT_BROWSER_NATIVE=1`) -- `cli/src/native/daemon.rs`, `cli/src/native/actions.rs`, `cli/src/native/browser.rs`, and the rest of `cli/src/native/`
When modifying browser automation logic (commands, actions, protocol handling), changes **must** be made in **both** paths:
| Node.js Path | Native Path |
|---|---|
| `src/actions.ts` | `cli/src/native/actions.rs` |
| `src/browser.ts` | `cli/src/native/browser.rs` |
| `src/daemon.ts` | `cli/src/native/daemon.rs` |
| `src/protocol.ts` | `cli/src/native/cdp/client.rs` |
| `src/snapshot.ts` | `cli/src/native/snapshot.rs` |
| `src/state-utils.ts` | `cli/src/native/state.rs` |
New commands must be implemented in both paths, or explicitly stubbed in the native path with a clear `"Not yet implemented: {action}"` error. The goal is eventual full migration to native, but until then both paths must stay in sync.
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
## Testing
+29 -82
View File
@@ -1,51 +1,34 @@
# agent-browser
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback.
Headless browser automation CLI for AI agents. Fast native Rust CLI.
## Installation
### Global Installation (recommended)
Installs the native Rust binary for maximum performance:
Installs the native Rust binary:
```bash
npm install -g agent-browser
agent-browser install # Download Chromium
agent-browser install # Download Chrome from Chrome for Testing (first time only)
```
This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead.
### Quick Start (no install)
Run directly with `npx` if you want to try it without installing globally:
```bash
npx agent-browser install # Download Chromium (first time only)
npx agent-browser open example.com
```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
### Project Installation (local dependency)
For projects that want to pin the version in `package.json`:
```bash
npm install agent-browser
npx agent-browser install
agent-browser install
```
Then use via `npx` or `package.json` scripts:
```bash
npx agent-browser open example.com
```
Then use via `package.json` scripts or by invoking `agent-browser` directly.
### Homebrew (macOS)
```bash
brew install agent-browser
agent-browser install # Download Chromium
agent-browser install # Download Chrome from Chrome for Testing (first time only)
```
### From Source
@@ -66,9 +49,13 @@ On Linux, install system dependencies:
```bash
agent-browser install --with-deps
# or manually: npx playwright install-deps chromium
```
### Requirements
- **Chrome** - Run `agent-browser install` to download Chrome from [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) (Google's official automation channel). No Playwright or Node.js required for the daemon.
- **Rust** - Only needed when building from source (see From Source above).
## Quick Start
```bash
@@ -322,7 +309,7 @@ agent-browser reload # Reload page
### Setup
```bash
agent-browser install # Download Chromium browser
agent-browser install # Download Chrome from Chrome for Testing (Google's official automation channel)
agent-browser install --with-deps # Also install system deps (Linux)
```
@@ -506,7 +493,7 @@ The `-C` flag is useful for modern web apps that use custom clickable elements (
The `--annotate` flag overlays numbered labels on interactive elements in the screenshot. Each label `[N]` corresponds to ref `@eN`, so the same refs work for both visual and text-based workflows.
In native mode, annotated screenshots are supported on the CDP-backed browser path (`--native` with Chromium/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`.
Annotated screenshots are supported on the CDP-backed browser path (Chrome/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`.
```bash
agent-browser screenshot --annotate
@@ -561,8 +548,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `--action-policy <path>` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) |
| `--confirm-actions <list>` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) |
| `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) |
| `--engine <name>` | Browser engine: `chrome` (default), `lightpanda`; implies `--native` (or `AGENT_BROWSER_ENGINE` env) |
| `--native` | [Experimental] Use native Rust daemon instead of Node.js (or `AGENT_BROWSER_NATIVE` env) |
| `--engine <name>` | Browser engine: `chrome` (default), `lightpanda` (or `AGENT_BROWSER_ENGINE` env) |
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
| `--debug` | Debug output |
@@ -606,7 +592,7 @@ Auto-discovered config files that are missing are silently ignored. If `--config
## Default Timeout
The default Playwright timeout for standard operations (clicks, waits, fills, etc.) is 25 seconds. This is intentionally below the CLI's 30-second IPC read timeout so that Playwright returns a proper error instead of the CLI timing out with EAGAIN.
The default timeout for standard operations (clicks, waits, fills, etc.) is 25 seconds. This is intentionally below the CLI's 30-second IPC read timeout so that the daemon returns a proper error instead of the CLI timing out with EAGAIN.
Override the default timeout via environment variable:
@@ -615,11 +601,11 @@ Override the default timeout via environment variable:
export AGENT_BROWSER_DEFAULT_TIMEOUT=45000
```
> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before Playwright responds. The CLI retries transient errors automatically, but response times will increase.
> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before the daemon responds. The CLI retries transient errors automatically, but response times will increase.
| Variable | Description |
| ------------------------------- | ------------------------------------------------- |
| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default Playwright timeout in ms (default: 25000) |
| ------------------------------- | ---------------------------------------- |
| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default operation timeout in ms (default: 25000) |
## Selectors
@@ -1009,61 +995,22 @@ await browser.stopScreencast();
agent-browser uses a client-daemon architecture:
1. **Rust CLI** (fast native binary) - Parses commands, communicates with daemon
2. **Node.js Daemon** (default) - Manages Playwright browser instance
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
4. **Fallback** - If native binary unavailable, uses Node.js directly
1. **Rust CLI** - Parses commands, communicates with daemon
2. **Rust Daemon** - Pure Rust daemon using direct CDP, no Node.js required
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
The daemon starts automatically on first command and persists between commands for fast subsequent operations. To auto-shutdown the daemon after a period of inactivity, set `AGENT_BROWSER_IDLE_TIMEOUT_MS` (value in milliseconds). When set, the daemon closes the browser and exits after receiving no commands for the specified duration.
**Browser Engine:** Uses Chromium by default. The default Node.js daemon also supports Firefox and WebKit via Playwright. The experimental native daemon speaks Chrome DevTools Protocol (CDP) directly and supports Chromium-based browsers and Safari (via WebDriver).
## Experimental: Native Mode
The native daemon is a pure Rust implementation that communicates with Chrome directly via CDP, eliminating the Node.js and Playwright dependencies. It is currently **experimental** and opt-in.
### Enabling Native Mode
```bash
# Via flag
agent-browser --native open example.com
# Via environment variable (recommended for persistent use)
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
Or add to your config file (`agent-browser.json`):
```json
{ "native": true }
```
### What's Different
| | Default (Node.js) | Native (`--native`) |
| ------------------- | --------------------------- | -------------------------------- |
| **Runtime** | Node.js + Playwright | Pure Rust binary |
| **Protocol** | Playwright protocol | Direct CDP / WebDriver |
| **Install size** | Larger (Node.js + npm deps) | Smaller (single binary) |
| **Browser support** | Chromium, Firefox, WebKit | Chromium, Safari (via WebDriver) |
| **Stability** | Stable | Experimental |
### Known Limitations
- Firefox and WebKit are not yet supported (Chromium and Safari only)
- Some Playwright-specific features (tracing format, HAR export) are not available
- The native daemon and Node.js daemon share the same session socket, so you cannot run both simultaneously for the same session. Use `agent-browser close` before switching modes.
**Browser Engine:** Uses Chrome (from Chrome for Testing) by default. The `--engine` flag selects between `chrome` and `lightpanda`. Supported browsers: Chromium/Chrome (via CDP) and Safari (via WebDriver for iOS).
## Platforms
| Platform | Binary | Fallback |
| ----------- | ----------- | -------- |
| macOS ARM64 | Native Rust | Node.js |
| macOS x64 | Native Rust | Node.js |
| Linux ARM64 | Native Rust | Node.js |
| Linux x64 | Native Rust | Node.js |
| Windows x64 | Native Rust | Node.js |
| Platform | Binary |
| ----------- | ----------- |
| macOS ARM64 | Native Rust |
| macOS x64 | Native Rust |
| Linux ARM64 | Native Rust |
| Linux x64 | Native Rust |
| Windows x64 | Native Rust |
## Usage with AI Agents
+4
View File
@@ -0,0 +1,4 @@
# Vercel Sandbox credentials
SANDBOX_VERCEL_TOKEN=
SANDBOX_VERCEL_TEAM_ID=
SANDBOX_VERCEL_PROJECT_ID=
+2
View File
@@ -0,0 +1,2 @@
node_modules/
results.json
+76
View File
@@ -0,0 +1,76 @@
# agent-browser Daemon Benchmarks
Compares command latency and system metrics between the **Node.js daemon** (published npm version) and the **Rust native daemon** (built from source), running inside a [Vercel Sandbox](https://vercel.com/docs/sandbox) microVM.
## What it measures
**Command latency** -- per-scenario timing with warmup, multiple iterations, and stddev:
- `navigate` -- page load round-trip
- `snapshot` -- accessibility tree generation
- `screenshot` -- viewport capture
- `evaluate` -- JavaScript execution
- `click` -- element interaction
- `fill` -- form input
- `agent-loop` -- snapshot/click/snapshot cycle (typical AI agent pattern)
- `full-workflow` -- realistic 7-command sequence
**System metrics** -- collected while the daemon is running:
- Cold start time (daemon spawn + browser launch)
- Binary size and total distribution size (including browser download)
- Daemon RSS and peak RSS (separated from browser process memory)
- Browser RSS (Chrome processes, same for both daemons)
- Daemon CPU time
- Process counts
## Prerequisites
- Node.js 18+
- pnpm
- Vercel Sandbox credentials (token, team ID, project ID)
## Setup
```bash
cd benchmarks
pnpm install
cp .env.example .env
```
Fill in your Vercel Sandbox credentials in `.env`:
```
SANDBOX_VERCEL_TOKEN=your_token
SANDBOX_VERCEL_TEAM_ID=your_team_id
SANDBOX_VERCEL_PROJECT_ID=your_project_id
```
## Usage
```bash
pnpm bench # 10 iterations, 1 warmup, 8 vCPUs
pnpm bench -- --iterations 20 # more iterations for tighter stats
pnpm bench -- --warmup 2 # extra warmup iterations
pnpm bench -- --json # write results.json
pnpm bench -- --branch main # build native from a different branch
pnpm bench -- --vcpus 16 # more vCPUs (faster Rust build)
```
## How it works
1. Creates a Vercel Sandbox (Amazon Linux, configurable vCPUs)
2. Installs Chromium system dependencies
3. **Phase 1 -- Node.js daemon**: installs `agent-browser` from npm (last version with the Node daemon), runs all scenarios, collects metrics
4. **Phase 2 -- Rust native daemon**: installs Rust toolchain, clones the repo, runs `cargo build --release`, replaces the binary, runs the same scenarios, collects metrics
5. Prints comparison tables and optionally writes `results.json`
## Interpreting results
**Command latency** is dominated by Chrome (CDP round-trips), not the daemon. Both daemons are thin relays between the CLI and Chrome, so per-command speedups are typically small. The stddev column helps distinguish real differences from noise.
**Where the native daemon wins** is in cold start (no Node.js runtime to boot), daemon memory (single Rust binary vs V8 heap), and distribution size (no Playwright dependency).
The **daemon RSS** metric isolates the daemon process memory from Chrome. This is the apples-to-apples comparison -- both daemons talk to the same Chrome, but Node.js adds ~140 MB of V8 overhead while the Rust daemon uses ~7 MB.
**Distribution size** includes the daemon plus its browser download. The Node version includes the npm package + Playwright's bundled Chromium. The Rust version is just the binary + Chrome for Testing.
+900
View File
@@ -0,0 +1,900 @@
/**
* Node.js Daemon vs Rust Native Daemon benchmark.
*
* Compares the last published npm version (Node.js daemon) against the
* Rust-only build from a given branch, running real agent-browser commands
* inside a Vercel Sandbox.
*
* Captures:
* - Command latency (per-scenario, with warmup + measured iterations + stddev)
* - Cold start time (first launch to daemon ready)
* - Daemon memory (RSS, peak RSS) separated from browser memory
* - Daemon CPU time
* - Process tree (daemon + browser children)
* - Binary and distribution size on disk
*
* Usage:
* pnpm bench # default: 10 iterations, 1 warmup
* pnpm bench -- --iterations 20 # override iterations
* pnpm bench -- --warmup 2 # override warmup count
* pnpm bench -- --json # write results.json
* pnpm bench -- --branch my-branch # override native branch (default: ctate/native-2)
* pnpm bench -- --vcpus 8 # sandbox vCPUs (default: 8, higher = faster Rust build)
*/
import { Sandbox } from "@vercel/sandbox";
import { readFileSync, writeFileSync } from "fs";
import { scenarios, type Scenario } from "./scenarios.js";
// ---------------------------------------------------------------------------
// Env
// ---------------------------------------------------------------------------
function loadEnv() {
try {
const content = readFileSync(".env", "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq);
let val = trimmed.slice(eq + 1);
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
process.env[key] = val;
}
} catch {}
}
loadEnv();
const credentials = {
token: process.env.SANDBOX_VERCEL_TOKEN!,
teamId: process.env.SANDBOX_VERCEL_TEAM_ID!,
projectId: process.env.SANDBOX_VERCEL_PROJECT_ID!,
};
if (!credentials.token || !credentials.teamId || !credentials.projectId) {
console.error(
"Missing credentials. Set SANDBOX_VERCEL_TOKEN, SANDBOX_VERCEL_TEAM_ID, SANDBOX_VERCEL_PROJECT_ID in .env",
);
process.exit(1);
}
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
let iterations = 10;
let warmup = 1;
let json = false;
let branch = "ctate/native-2";
let vcpus = 8;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--iterations" && args[i + 1]) {
iterations = parseInt(args[++i], 10);
} else if (args[i] === "--warmup" && args[i + 1]) {
warmup = parseInt(args[++i], 10);
} else if (args[i] === "--json") {
json = true;
} else if (args[i] === "--branch" && args[i + 1]) {
branch = args[++i];
} else if (args[i] === "--vcpus" && args[i + 1]) {
vcpus = parseInt(args[++i], 10);
}
}
return { iterations, warmup, json, branch, vcpus };
}
const config = parseArgs();
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TIMEOUT_MS = 30 * 60 * 1000;
const REPO_URL = "https://github.com/vercel-labs/agent-browser.git";
const CHROMIUM_SYSTEM_DEPS = [
"nss",
"nspr",
"libxkbcommon",
"atk",
"at-spi2-atk",
"at-spi2-core",
"libXcomposite",
"libXdamage",
"libXrandr",
"libXfixes",
"libXcursor",
"libXi",
"libXtst",
"libXScrnSaver",
"libXext",
"mesa-libgbm",
"libdrm",
"mesa-libGL",
"mesa-libEGL",
"cups-libs",
"alsa-lib",
"pango",
"cairo",
"gtk3",
"dbus-libs",
];
// ---------------------------------------------------------------------------
// Sandbox helpers
// ---------------------------------------------------------------------------
type SandboxInstance = InstanceType<typeof Sandbox>;
async function run(
sandbox: SandboxInstance,
cmd: string,
args: string[],
): Promise<string> {
const result = await sandbox.runCommand(cmd, args);
const stdout = await result.stdout();
const stderr = await result.stderr();
if (result.exitCode !== 0) {
throw new Error(
`Command failed (exit ${result.exitCode}): ${cmd} ${args.join(" ")}\n${stderr || stdout}`,
);
}
return stdout;
}
async function shell(sandbox: SandboxInstance, script: string): Promise<string> {
return run(sandbox, "sh", ["-c", script]);
}
async function shellSafe(sandbox: SandboxInstance, script: string): Promise<string> {
const result = await sandbox.runCommand("sh", ["-c", script]);
return (await result.stdout()).trim();
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
interface Stats {
avgMs: number;
stddevMs: number;
minMs: number;
maxMs: number;
p50Ms: number;
samples: number[];
}
function computeStats(samples: number[]): Stats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const avg = sum / sorted.length;
const variance =
sorted.reduce((acc, v) => acc + (v - avg) ** 2, 0) / sorted.length;
return {
avgMs: Math.round(avg),
stddevMs: Math.round(Math.sqrt(variance)),
minMs: sorted[0],
maxMs: sorted[sorted.length - 1],
p50Ms: sorted[Math.floor(sorted.length / 2)],
samples: sorted,
};
}
// ---------------------------------------------------------------------------
// Metrics collection
// ---------------------------------------------------------------------------
interface ProcessMetrics {
pid: number;
rssKb: number;
vszKb: number;
cpuPercent: number;
memPercent: number;
cpuTimeSec: number;
command: string;
}
interface DaemonMetrics {
coldStartMs: number;
binarySizeBytes: number;
distributionSizeBytes: number;
daemonProcesses: ProcessMetrics[];
browserProcesses: ProcessMetrics[];
daemonRssKb: number;
browserRssKb: number;
daemonPeakRssKb: number;
daemonCpuTimeSec: number;
totalCpuTimeSec: number;
}
async function findDaemonPids(
sandbox: SandboxInstance,
_session: string,
): Promise<number[]> {
// The daemon process name is "agent-browser" but session/daemon flags are
// env vars, not command-line args, so we can't grep them from `ps`.
// Instead, find all agent-browser processes that look like long-running daemons
// (not short-lived CLI invocations -- those exit immediately).
const raw = await shellSafe(
sandbox,
`pgrep -x agent-browser 2>/dev/null || true`,
);
if (!raw) {
// Fallback: broader match on process name
const fallback = await shellSafe(
sandbox,
`pgrep -f 'agent-browser' 2>/dev/null | head -5 || true`,
);
if (!fallback) return [];
return fallback.split("\n").map(Number).filter(Boolean);
}
return raw.split("\n").map(Number).filter(Boolean);
}
async function collectProcessMetrics(
sandbox: SandboxInstance,
pid: number,
): Promise<ProcessMetrics | null> {
const raw = await shellSafe(
sandbox,
`ps -p ${pid} -o pid=,rss=,vsz=,%cpu=,%mem=,cputime=,comm= 2>/dev/null || true`,
);
if (!raw) return null;
const parts = raw.trim().split(/\s+/);
if (parts.length < 7) return null;
// Parse cputime "HH:MM:SS" or "MM:SS" to seconds
const timeParts = parts[5].split(":").map(Number);
let cpuTimeSec = 0;
if (timeParts.length === 3) {
cpuTimeSec = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
} else if (timeParts.length === 2) {
cpuTimeSec = timeParts[0] * 60 + timeParts[1];
}
return {
pid: Number(parts[0]),
rssKb: Number(parts[1]),
vszKb: Number(parts[2]),
cpuPercent: Number(parts[3]),
memPercent: Number(parts[4]),
cpuTimeSec,
command: parts.slice(6).join(" "),
};
}
async function getPeakRssKb(
sandbox: SandboxInstance,
pid: number,
): Promise<number> {
const raw = await shellSafe(
sandbox,
`cat /proc/${pid}/status 2>/dev/null | grep VmHWM | awk '{print $2}' || echo 0`,
);
return Number(raw) || 0;
}
async function getChildPids(
sandbox: SandboxInstance,
pid: number,
): Promise<number[]> {
const raw = await shellSafe(
sandbox,
`pgrep -P ${pid} 2>/dev/null || true`,
);
if (!raw) return [];
return raw.split("\n").map(Number).filter(Boolean);
}
async function getAllDescendantPids(
sandbox: SandboxInstance,
pid: number,
): Promise<number[]> {
const all: number[] = [];
const queue = [pid];
while (queue.length > 0) {
const current = queue.shift()!;
all.push(current);
const children = await getChildPids(sandbox, current);
queue.push(...children);
}
return all;
}
async function collectDaemonMetrics(
sandbox: SandboxInstance,
session: string,
coldStartMs: number,
binarySizeBytes: number,
distributionSizeBytes: number,
): Promise<DaemonMetrics> {
// Find daemon PIDs -- the agent-browser process itself
const daemonPids = await findDaemonPids(sandbox, session);
// Also find the full process tree (daemon + Chrome children)
let allPids: number[] = [];
for (const pid of daemonPids) {
const descendants = await getAllDescendantPids(sandbox, pid);
allPids.push(...descendants);
}
allPids = [...new Set(allPids)];
// If no daemon PIDs found via pgrep, fall back to grabbing all
// agent-browser and chrome processes for metrics
if (allPids.length === 0) {
const fallback = await shellSafe(
sandbox,
`ps -eo pid,comm | grep -E 'agent-browser|chrome' | grep -v grep | awk '{print $1}' || true`,
);
if (fallback) {
allPids = fallback.split("\n").map(Number).filter(Boolean);
}
}
const daemonProcs: ProcessMetrics[] = [];
const browserProcs: ProcessMetrics[] = [];
let daemonPeakRssKb = 0;
for (const pid of allPids) {
const metrics = await collectProcessMetrics(sandbox, pid);
if (!metrics) continue;
const isBrowser = /chrome|chromium/i.test(metrics.command);
if (isBrowser) {
browserProcs.push(metrics);
} else {
daemonProcs.push(metrics);
const peak = await getPeakRssKb(sandbox, pid);
daemonPeakRssKb = Math.max(daemonPeakRssKb, peak);
}
}
const daemonRssKb = daemonProcs.reduce((sum, p) => sum + p.rssKb, 0);
const browserRssKb = browserProcs.reduce((sum, p) => sum + p.rssKb, 0);
const daemonCpuTimeSec = daemonProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
const allProcs = [...daemonProcs, ...browserProcs];
const totalCpuTimeSec = allProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
return {
coldStartMs,
binarySizeBytes,
distributionSizeBytes,
daemonProcesses: daemonProcs,
browserProcesses: browserProcs,
daemonRssKb,
browserRssKb,
daemonPeakRssKb,
daemonCpuTimeSec,
totalCpuTimeSec,
};
}
async function getBinarySize(
sandbox: SandboxInstance,
): Promise<number> {
// Follow symlinks to get the real binary/script size
const raw = await shellSafe(
sandbox,
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
);
return Number(raw) || 0;
}
async function getDistributionSize(
sandbox: SandboxInstance,
mode: DaemonMode,
): Promise<number> {
if (mode === "node") {
// Total size of the npm package + Playwright browser
const npmPkg = await shellSafe(
sandbox,
`du -sb "$(npm root -g)/agent-browser" 2>/dev/null | awk '{print $1}' || echo 0`,
);
const pwBrowser = await shellSafe(
sandbox,
`du -sb "$HOME/.cache/ms-playwright" 2>/dev/null | awk '{print $1}' || echo 0`,
);
return (Number(npmPkg) || 0) + (Number(pwBrowser) || 0);
} else {
// Rust binary + Chrome for Testing (checks multiple possible cache paths)
const binary = await shellSafe(
sandbox,
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
);
const chrome = await shellSafe(
sandbox,
[
`size=0`,
`for d in "$HOME/.cache/agent-browser" "$HOME/.cache/ms-playwright" "$HOME/.agent-browser/chrome"; do`,
` if [ -d "$d" ]; then size=$(du -sb "$d" 2>/dev/null | awk '{print $1}'); break; fi`,
`done`,
`echo $size`,
].join("; "),
);
return (Number(binary) || 0) + (Number(chrome) || 0);
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function formatKb(kb: number): string {
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
return `${kb} KB`;
}
// ---------------------------------------------------------------------------
// Scenario runner
// ---------------------------------------------------------------------------
type DaemonMode = "node" | "native";
function daemonEnv(mode: DaemonMode): Record<string, string> {
return { AGENT_BROWSER_SESSION: `bench-${mode}` };
}
async function agentBrowser(
sandbox: SandboxInstance,
args: string[],
mode: DaemonMode,
): Promise<void> {
const result = await sandbox.runCommand({
cmd: "agent-browser",
args,
env: daemonEnv(mode),
});
if (result.exitCode !== 0) {
const stderr = await result.stderr();
const stdout = await result.stdout();
throw new Error(
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
);
}
}
async function timedAgentBrowser(
sandbox: SandboxInstance,
args: string[],
mode: DaemonMode,
): Promise<number> {
const start = Date.now();
const result = await sandbox.runCommand({
cmd: "agent-browser",
args,
env: daemonEnv(mode),
});
const elapsed = Date.now() - start;
if (result.exitCode !== 0) {
const stderr = await result.stderr();
const stdout = await result.stdout();
throw new Error(
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
);
}
return elapsed;
}
interface ScenarioResult {
name: string;
description: string;
stats: Stats;
error?: string;
}
async function runScenario(
sandbox: SandboxInstance,
scenario: Scenario,
mode: DaemonMode,
iterations: number,
warmup: number,
): Promise<ScenarioResult> {
try {
if (scenario.setup) {
for (const cmd of scenario.setup) {
await agentBrowser(sandbox, cmd, mode);
}
}
for (let w = 0; w < warmup; w++) {
for (const cmd of scenario.commands) {
await agentBrowser(sandbox, cmd, mode);
}
}
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
let totalMs = 0;
for (const cmd of scenario.commands) {
totalMs += await timedAgentBrowser(sandbox, cmd, mode);
}
samples.push(totalMs);
}
if (scenario.teardown) {
for (const cmd of scenario.teardown) {
await agentBrowser(sandbox, cmd, mode);
}
}
return {
name: scenario.name,
description: scenario.description,
stats: computeStats(samples),
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return {
name: scenario.name,
description: scenario.description,
stats: { avgMs: -1, stddevMs: -1, minMs: -1, maxMs: -1, p50Ms: -1, samples: [] },
error: message,
};
}
}
// ---------------------------------------------------------------------------
// Benchmark phases
// ---------------------------------------------------------------------------
interface DaemonResults {
mode: DaemonMode;
label: string;
scenarios: ScenarioResult[];
metrics: DaemonMetrics;
}
async function benchmarkDaemon(
sandbox: SandboxInstance,
mode: DaemonMode,
label: string,
): Promise<DaemonResults> {
console.log(`\n--- ${label} ---`);
// Measure sizes before launch
const binarySizeBytes = await getBinarySize(sandbox);
const distributionSizeBytes = await getDistributionSize(sandbox, mode);
// Cold start: time the first launch (daemon spawn + browser launch)
const coldStartBegin = Date.now();
await agentBrowser(sandbox, ["open", "about:blank"], mode);
const coldStartMs = Date.now() - coldStartBegin;
console.log(` Cold start: ${coldStartMs}ms`);
console.log(` Binary size: ${formatBytes(binarySizeBytes)}`);
console.log(` Distribution size: ${formatBytes(distributionSizeBytes)}`);
// Run all scenarios
const results: ScenarioResult[] = [];
for (const scenario of scenarios) {
process.stdout.write(` ${scenario.name} `);
const result = await runScenario(
sandbox,
scenario,
mode,
config.iterations,
config.warmup,
);
if (result.error) {
console.log(`FAILED: ${result.error.slice(0, 120)}`);
} else {
const dots = ".".repeat(Math.max(1, 30 - scenario.name.length));
const s = result.stats;
console.log(
`${dots} ${s.avgMs}ms avg +/-${s.stddevMs}ms (p50: ${s.p50Ms}ms, min: ${s.minMs}ms, max: ${s.maxMs}ms)`,
);
}
results.push(result);
}
// Collect system metrics after scenarios (daemon is still running)
const session = `bench-${mode}`;
const metrics = await collectDaemonMetrics(
sandbox,
session,
coldStartMs,
binarySizeBytes,
distributionSizeBytes,
);
// Also grab a full process snapshot for context
const psOutput = await shellSafe(
sandbox,
`ps aux --sort=-rss | head -20`,
);
console.log(`\n Process snapshot (top by RSS):`);
for (const line of psOutput.split("\n").slice(0, 10)) {
console.log(` ${line}`);
}
console.log(`\n Daemon processes (${metrics.daemonProcesses.length}):`);
console.log(` RSS: ${formatKb(metrics.daemonRssKb)} (peak: ${formatKb(metrics.daemonPeakRssKb)})`);
console.log(` CPU time: ${metrics.daemonCpuTimeSec.toFixed(1)}s`);
for (const p of metrics.daemonProcesses) {
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
}
console.log(` Browser processes (${metrics.browserProcesses.length}):`);
console.log(` RSS: ${formatKb(metrics.browserRssKb)}`);
for (const p of metrics.browserProcesses) {
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
}
await agentBrowser(sandbox, ["close"], mode);
console.log(` Browser closed.`);
return { mode, label, scenarios: results, metrics };
}
// ---------------------------------------------------------------------------
// Install helpers
// ---------------------------------------------------------------------------
async function installChromiumDeps(sandbox: SandboxInstance) {
console.log("Installing Chromium system dependencies...");
await shell(
sandbox,
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
);
}
async function installNodeDaemon(sandbox: SandboxInstance) {
console.log("Installing agent-browser from npm (Node.js daemon)...");
await run(sandbox, "npm", ["install", "-g", "agent-browser"]);
await run(sandbox, "npx", ["agent-browser", "install"]);
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
console.log(` version: ${version.trim()}`);
}
async function installNativeDaemon(sandbox: SandboxInstance, branch: string) {
console.log(`\nBuilding native daemon from ${branch}...`);
console.log(" Installing build tools and Rust toolchain...");
const rustStart = Date.now();
await shell(
sandbox,
"sudo dnf install -y gcc gcc-c++ make perl-core openssl-devel 2>&1",
);
await shell(
sandbox,
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 2>&1",
);
console.log(` Rust + build tools installed (${Math.round((Date.now() - rustStart) / 1000)}s)`);
console.log(` Cloning repo (branch: ${branch})...`);
const cloneStart = Date.now();
await shell(
sandbox,
`git clone --depth 1 --branch ${branch} ${REPO_URL} /tmp/agent-browser 2>&1`,
);
console.log(` Cloned (${Math.round((Date.now() - cloneStart) / 1000)}s)`);
console.log(" Building release binary (cargo build --release)...");
const buildStart = Date.now();
await shell(
sandbox,
"source $HOME/.cargo/env && cd /tmp/agent-browser/cli && cargo build --release 2>&1",
);
console.log(` Built (${Math.round((Date.now() - buildStart) / 1000)}s)`);
const npmBinPath = (await shell(sandbox, "which agent-browser")).trim();
console.log(` Replacing ${npmBinPath} with native build...`);
await shell(
sandbox,
`sudo cp /tmp/agent-browser/cli/target/release/agent-browser ${npmBinPath}`,
);
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
console.log(` version: ${version.trim()}`);
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
function printResults(node: DaemonResults, native: DaemonResults) {
console.log("\n\n========== COMMAND LATENCY ==========\n");
const header =
"Scenario".padEnd(20) + "| Node avg +/-sd | Rust avg +/-sd | Speedup";
const sep = "-".repeat(20) + "|-----------------|-----------------|--------";
console.log(header);
console.log(sep);
for (let i = 0; i < node.scenarios.length; i++) {
const n = node.scenarios[i];
const r = native.scenarios[i];
const name = n.name.padEnd(20);
if (n.error || r.error) {
const nodeVal = n.error ? "FAILED".padEnd(15) : `${n.stats.avgMs}ms`.padEnd(15);
const rustVal = r.error ? "FAILED".padEnd(15) : `${r.stats.avgMs}ms`.padEnd(15);
console.log(`${name}| ${nodeVal} | ${rustVal} | --`);
continue;
}
const nodeVal = `${n.stats.avgMs} +/-${n.stats.stddevMs}ms`.padEnd(15);
const rustVal = `${r.stats.avgMs} +/-${r.stats.stddevMs}ms`.padEnd(15);
const speedup =
r.stats.avgMs > 0
? (n.stats.avgMs / r.stats.avgMs).toFixed(2) + "x"
: "--";
console.log(`${name}| ${nodeVal} | ${rustVal} | ${speedup.padStart(6)}`);
}
console.log("\n\n========== SYSTEM METRICS ==========\n");
const nm = node.metrics;
const rm = native.metrics;
function ratio(a: number, b: number): string {
if (b <= 0) return "--";
return (a / b).toFixed(2) + "x";
}
const metricRows: [string, string, string, string][] = [
[
"Cold start",
`${nm.coldStartMs}ms`,
`${rm.coldStartMs}ms`,
ratio(nm.coldStartMs, rm.coldStartMs),
],
[
"Binary size",
formatBytes(nm.binarySizeBytes),
formatBytes(rm.binarySizeBytes),
ratio(nm.binarySizeBytes, rm.binarySizeBytes),
],
[
"Distribution size",
formatBytes(nm.distributionSizeBytes),
formatBytes(rm.distributionSizeBytes),
ratio(nm.distributionSizeBytes, rm.distributionSizeBytes),
],
[
"Daemon RSS",
formatKb(nm.daemonRssKb),
formatKb(rm.daemonRssKb),
ratio(nm.daemonRssKb, rm.daemonRssKb),
],
[
"Daemon peak RSS",
formatKb(nm.daemonPeakRssKb),
formatKb(rm.daemonPeakRssKb),
ratio(nm.daemonPeakRssKb, rm.daemonPeakRssKb),
],
[
"Browser RSS",
formatKb(nm.browserRssKb),
formatKb(rm.browserRssKb),
ratio(nm.browserRssKb, rm.browserRssKb),
],
[
"Daemon CPU time",
`${nm.daemonCpuTimeSec.toFixed(1)}s`,
`${rm.daemonCpuTimeSec.toFixed(1)}s`,
ratio(nm.daemonCpuTimeSec, rm.daemonCpuTimeSec),
],
[
"Daemon processes",
String(nm.daemonProcesses.length),
String(rm.daemonProcesses.length),
"--",
],
[
"Browser processes",
String(nm.browserProcesses.length),
String(rm.browserProcesses.length),
"--",
],
];
const mHeader =
"Metric".padEnd(20) + "| Node".padEnd(14) + "| Rust".padEnd(14) + "| Ratio";
const mSep = "-".repeat(20) + "|" + "-".repeat(13) + "|" + "-".repeat(13) + "|--------";
console.log(mHeader);
console.log(mSep);
for (const [metric, nodeVal, rustVal, ratio] of metricRows) {
console.log(
`${metric.padEnd(20)}| ${nodeVal.padEnd(12)}| ${rustVal.padEnd(12)}| ${ratio}`,
);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("agent-browser Daemon Benchmark (Node.js vs Rust Native)");
console.log(`Branch: ${config.branch}`);
console.log(`Iterations: ${config.iterations} (+ ${config.warmup} warmup)`);
console.log(`vCPUs: ${config.vcpus}\n`);
console.log("Creating sandbox...");
const sandbox = await Sandbox.create({
...credentials,
timeout: TIMEOUT_MS,
runtime: "node22",
networkPolicy: "allow-all" as const,
resources: { vcpus: config.vcpus },
});
console.log(`Sandbox: ${sandbox.sandboxId}`);
try {
await installChromiumDeps(sandbox);
// Phase 1: Node.js daemon (from published npm package)
await installNodeDaemon(sandbox);
const nodeResults = await benchmarkDaemon(
sandbox,
"node",
"Node.js Daemon (npm)",
);
// Phase 2: Rust native daemon (built from branch)
await installNativeDaemon(sandbox, config.branch);
const nativeResults = await benchmarkDaemon(
sandbox,
"native",
`Rust Native Daemon (${config.branch})`,
);
printResults(nodeResults, nativeResults);
if (config.json) {
const output = {
timestamp: new Date().toISOString(),
branch: config.branch,
vcpus: config.vcpus,
iterations: config.iterations,
warmup: config.warmup,
node: {
scenarios: nodeResults.scenarios.map((s) => ({
name: s.name,
description: s.description,
...s.stats,
error: s.error,
})),
metrics: nodeResults.metrics,
},
native: {
scenarios: nativeResults.scenarios.map((s) => ({
name: s.name,
description: s.description,
...s.stats,
error: s.error,
})),
metrics: nativeResults.metrics,
},
};
writeFileSync("results.json", JSON.stringify(output, null, 2));
console.log("\nResults written to results.json");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`\nFatal error: ${message}`);
process.exit(1);
} finally {
try {
await sandbox.stop();
console.log("\nSandbox stopped.");
} catch {
console.warn("Warning: failed to stop sandbox.");
}
}
}
main();
+13
View File
@@ -0,0 +1,13 @@
{
"name": "agent-browser-benchmarks",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"bench": "tsx bench.ts"
},
"dependencies": {
"@vercel/sandbox": "^1.8.0",
"tsx": "^4.19.0"
}
}
+472
View File
@@ -0,0 +1,472 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@vercel/sandbox':
specifier: ^1.8.0
version: 1.8.1
tsx:
specifier: ^4.19.0
version: 4.21.0
packages:
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vercel/sandbox@1.8.1':
resolution: {integrity: sha512-txohjI20aMxZiAzBL/KJi5EqTYsesBdOyIOtpTIyebPLTqYtDYfNhQ4OeYiUcPMUo0XBt8gSet/rIdLQEjj3/A==}
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
b4a@1.8.0:
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
jsonlines@0.1.1:
resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
os-paths@4.4.0:
resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
engines: {node: '>= 6.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
undici@7.24.1:
resolution: {integrity: sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==}
engines: {node: '>=20.18.1'}
xdg-app-paths@5.1.0:
resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==}
engines: {node: '>=6'}
xdg-portable@7.3.0:
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
engines: {node: '>= 6.0'}
zod@3.24.4:
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
snapshots:
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@vercel/oidc@3.2.0': {}
'@vercel/sandbox@1.8.1':
dependencies:
'@vercel/oidc': 3.2.0
async-retry: 1.3.3
jsonlines: 0.1.1
ms: 2.1.3
picocolors: 1.1.1
tar-stream: 3.1.7
undici: 7.24.1
xdg-app-paths: 5.1.0
zod: 3.24.4
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
async-retry@1.3.3:
dependencies:
retry: 0.13.1
b4a@1.8.0: {}
bare-events@2.8.2: {}
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
fast-fifo@1.3.2: {}
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
jsonlines@0.1.1: {}
ms@2.1.3: {}
os-paths@4.4.0: {}
picocolors@1.1.1: {}
resolve-pkg-maps@1.0.0: {}
retry@0.13.1: {}
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
tar-stream@3.1.7:
dependencies:
b4a: 1.8.0
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
text-decoder@1.2.7:
dependencies:
b4a: 1.8.0
transitivePeerDependencies:
- react-native-b4a
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
undici@7.24.1: {}
xdg-app-paths@5.1.0:
dependencies:
xdg-portable: 7.3.0
xdg-portable@7.3.0:
dependencies:
os-paths: 4.4.0
zod@3.24.4: {}
+105
View File
@@ -0,0 +1,105 @@
/**
* Benchmark scenarios for comparing Node.js daemon vs Rust native daemon.
*
* Each scenario defines CLI commands run via `sandbox.runCommand("agent-browser", args)`.
* Setup/teardown commands run once and are not timed.
* The `commands` array is timed over N iterations.
*/
export interface Scenario {
name: string;
description: string;
setup?: string[][];
commands: string[][];
teardown?: string[][];
}
const FORM_HTML = [
"<html><head><title>Bench</title></head><body>",
"<h1>Benchmark Page</h1>",
"<input id='name' type='text' placeholder='Name'>",
"<input id='email' type='email' placeholder='Email'>",
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
"<input id='agree' type='checkbox'>",
"<textarea id='bio' placeholder='Bio'></textarea>",
"<button id='submit'>Submit</button>",
"<p id='status'>Ready</p>",
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
"<ul>",
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
"</ul>",
"</body></html>",
].join("");
const INJECT_FORM_SCRIPT = `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`;
const SETUP_PAGE: string[][] = [
["open", "about:blank"],
["eval", INJECT_FORM_SCRIPT],
];
export const scenarios: Scenario[] = [
{
name: "navigate",
description: "Page navigation (about:blank round-trip)",
commands: [["open", "about:blank"]],
},
{
name: "snapshot",
description: "DOM snapshot (accessibility tree)",
setup: SETUP_PAGE,
commands: [["snapshot"]],
},
{
name: "screenshot",
description: "Screenshot capture",
setup: SETUP_PAGE,
commands: [["screenshot"]],
},
{
name: "evaluate",
description: "JavaScript evaluation",
setup: SETUP_PAGE,
commands: [
[
"eval",
"document.title + ' ' + document.querySelectorAll('li').length",
],
],
},
{
name: "click",
description: "Element click interaction",
setup: SETUP_PAGE,
commands: [["click", "#link"]],
},
{
name: "fill",
description: "Form field fill",
setup: SETUP_PAGE,
commands: [["fill", "#name", "Benchmark User"]],
},
{
name: "agent-loop",
description: "AI agent loop: snapshot -> click -> snapshot (typical agent cycle)",
setup: SETUP_PAGE,
commands: [["snapshot"], ["click", "#link"], ["snapshot"]],
},
{
name: "full-workflow",
description:
"Realistic workflow: navigate, inject form, snapshot, click, fill, evaluate, screenshot",
commands: [
["open", "about:blank"],
["eval", INJECT_FORM_SCRIPT],
["snapshot"],
["click", "#link"],
["fill", "#name", "Agent User"],
[
"eval",
"document.getElementById('name').value",
],
["screenshot"],
],
},
];
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true
},
"include": ["*.ts"]
}
+41 -1
View File
@@ -65,6 +65,7 @@ dependencies = [
"url",
"uuid",
"windows-sys 0.52.0",
"zip",
]
[[package]]
@@ -527,6 +528,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -1414,7 +1416,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -2090,6 +2092,12 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -2777,12 +2785,44 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
+1
View File
@@ -22,6 +22,7 @@ sha2 = "0.10"
aes-gcm = "0.10"
async-trait = "0.1"
similar = "2"
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+1 -3
View File
@@ -1061,7 +1061,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
// === Recording (Playwright native video recording) ===
// === Recording (browser video recording) ===
"record" => {
const VALID: &[&str] = &["start", "stop", "restart"];
match rest.first().copied() {
@@ -2167,7 +2167,6 @@ mod tests {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_native: false,
cli_headed: false,
annotate: false,
color_scheme: None,
@@ -2178,7 +2177,6 @@ mod tests {
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
native: false,
engine: None,
screenshot_dir: None,
screenshot_quality: None,
+2 -86
View File
@@ -233,7 +233,6 @@ pub struct DaemonOptions<'a> {
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
pub engine: Option<&'a str>,
}
@@ -359,25 +358,11 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
}
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary)
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
// On Windows, canonicalize() returns \\?\ prefixed extended-length paths.
// Node.js cannot handle these, so strip the prefix.
#[cfg(windows)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
#[allow(unused_assignments)]
let mut daemon_child: Option<std::process::Child> = None;
if opts.native {
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
@@ -393,71 +378,6 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
});
}
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?,
);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?,
);
}
} else {
// Default mode: spawn Node.js daemon (Playwright)
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -471,11 +391,8 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
{
use std::os::windows::process::CommandExt;
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
@@ -490,7 +407,6 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
}
for _ in 0..50 {
if daemon_ready(session) {
-15
View File
@@ -41,7 +41,6 @@ pub struct Config {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
@@ -87,7 +86,6 @@ impl Config {
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
engine: other.engine.or(self.engine),
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
@@ -247,7 +245,6 @@ pub struct Flags {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
@@ -266,7 +263,6 @@ pub struct Flags {
pub cli_allow_file_access: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_native: bool,
pub cli_headed: bool,
}
@@ -358,7 +354,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
.ok()
@@ -382,7 +377,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_native: false,
cli_headed: false,
};
@@ -604,14 +598,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
flags.cli_native = true;
if consumed {
i += 1;
}
}
"--screenshot-dir" => {
if let Some(s) = args.get(i + 1) {
flags.screenshot_dir = Some(s.clone());
@@ -675,7 +661,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
+373 -60
View File
@@ -1,11 +1,375 @@
use crate::color;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{exit, Command, Stdio};
const LAST_KNOWN_GOOD_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
pub fn get_browsers_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agent-browser")
.join("browsers")
}
pub fn find_installed_chrome() -> Option<PathBuf> {
let browsers_dir = get_browsers_dir();
if !browsers_dir.exists() {
return None;
}
let mut versions: Vec<_> = fs::read_dir(&browsers_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("chrome-"))
})
.collect();
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for entry in versions {
if let Some(bin) = chrome_binary_in_dir(&entry.path()) {
if bin.exists() {
return Some(bin);
}
}
}
None
}
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let app =
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if app.exists() {
return Some(app);
}
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if inner.exists() {
return Some(inner);
}
let inner_x64 = dir.join(
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
);
if inner_x64.exists() {
return Some(inner_x64);
}
None
}
#[cfg(target_os = "linux")]
{
let bin = dir.join("chrome");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-linux64/chrome");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(target_os = "windows")]
{
let bin = dir.join("chrome.exe");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-win64/chrome.exe");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}
fn platform_key() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"mac-arm64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"mac-x64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux64"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"win64"
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
{
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
// can still be used for other commands like `connect`. The install path
// guards against this at runtime before calling platform_key().
panic!("Unsupported platform for Chrome for Testing download")
}
}
async fn fetch_download_url() -> Result<(String, String), String> {
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
.await
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse version info: {}", e))?;
let channel = body
.get("channels")
.and_then(|c| c.get("Stable"))
.ok_or("No Stable channel found in version info")?;
let version = channel
.get("version")
.and_then(|v| v.as_str())
.ok_or("No version string found")?
.to_string();
let platform = platform_key();
let url = channel
.get("downloads")
.and_then(|d| d.get("chrome"))
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter().find_map(|entry| {
if entry.get("platform")?.as_str()? == platform {
Some(entry.get("url")?.as_str()?.to_string())
} else {
None
}
})
})
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
Ok((version, url))
}
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
let resp = reqwest::get(url)
.await
.map_err(|e| format!("Download failed: {}", e))?;
let total = resp.content_length();
let mut bytes = Vec::new();
let mut stream = resp;
let mut downloaded: u64 = 0;
let mut last_pct: u64 = 0;
loop {
let chunk = stream
.chunk()
.await
.map_err(|e| format!("Download error: {}", e))?;
match chunk {
Some(data) => {
downloaded += data.len() as u64;
bytes.extend_from_slice(&data);
if let Some(total) = total {
let pct = (downloaded * 100) / total;
if pct >= last_pct + 5 {
last_pct = pct;
let mb = downloaded as f64 / 1_048_576.0;
let total_mb = total as f64 / 1_048_576.0;
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
let _ = io::stderr().flush();
}
}
}
None => break,
}
}
eprintln!();
Ok(bytes)
}
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
let cursor = io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let enclosed = match file.enclosed_name() {
Some(name) => name.to_owned(),
None => continue,
};
let raw_name = enclosed.to_string_lossy().to_string();
let rel_path = raw_name
.strip_prefix("chrome-")
.and_then(|s| s.split_once('/'))
.map(|(_, rest)| rest.to_string())
.unwrap_or(raw_name.clone());
if rel_path.is_empty() {
continue;
}
let out_path = dest.join(&rel_path);
// Defense-in-depth: ensure the resolved path is inside dest
if !out_path.starts_with(dest) {
continue;
}
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create parent dir {}: {}", parent.display(), e)
})?;
}
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
io::copy(&mut file, &mut out_file)
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
}
}
}
}
Ok(())
}
pub fn run_install(with_deps: bool) {
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
eprintln!(
"{} Chrome for Testing does not provide Linux ARM64 builds.",
color::error_indicator()
);
eprintln!(" Install Chromium from your system package manager instead:");
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
eprintln!(" sudo dnf install chromium # Fedora");
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
exit(1);
}
let is_linux = cfg!(target_os = "linux");
if is_linux {
if with_deps {
install_linux_deps();
} else {
println!(
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!();
}
}
println!("{}", color::cyan("Installing Chrome..."));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!(
"{} Failed to create runtime: {}",
color::error_indicator(),
e
);
exit(1);
});
let (version, url) = match rt.block_on(fetch_download_url()) {
Ok(v) => v,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
let dest = get_browsers_dir().join(format!("chrome-{}", version));
if let Some(bin) = chrome_binary_in_dir(&dest) {
if bin.exists() {
println!(
"{} Chrome {} is already installed",
color::success_indicator(),
version
);
return;
}
}
println!(" Downloading Chrome {} for {}", version, platform_key());
println!(" {}", url);
let bytes = match rt.block_on(download_bytes(&url)) {
Ok(b) => b,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
match extract_zip(bytes, &dest) {
Ok(()) => {
println!(
"{} Chrome {} installed successfully",
color::success_indicator(),
version
);
println!(" Location: {}", dest.display());
if is_linux && !with_deps {
println!();
println!(
"{} If you see \"shared library\" errors when running, use:",
color::yellow("Note:")
);
println!(" agent-browser install --with-deps");
}
}
Err(e) => {
let _ = fs::remove_dir_all(&dest);
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
}
fn install_linux_deps() {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
@@ -122,71 +486,20 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator())
println!(
"{} System dependencies installed",
color::success_indicator()
)
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
}
} else {
println!(
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("{}", color::cyan("Installing Chromium browser..."));
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
// Pass the entire command as a single string to /c to handle paths with spaces.
#[cfg(windows)]
let status = Command::new("cmd")
.args(["/c", "npx playwright install chromium"])
.status();
#[cfg(not(windows))]
let status = Command::new("npx")
.args(["playwright", "install", "chromium"])
.status();
match status {
Ok(s) if s.success() => {
println!(
"{} Chromium installed successfully",
color::success_indicator()
);
if is_linux && !with_deps {
println!();
println!(
"{} If you see \"shared library\" errors when running, use:",
color::yellow("Note:")
);
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux {
println!(
"{} Try installing system dependencies first:",
color::yellow("Tip:")
);
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
exit(1);
}
Err(e) => eprintln!(
"{} Could not run install command: {}",
color::warning_indicator(),
e
),
}
}
+1 -125
View File
@@ -27,9 +27,6 @@ use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
fn serialize_json_value(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| {
r#"{"success":false,"error":"Failed to serialize JSON response"}"#.to_string()
@@ -55,110 +52,6 @@ fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
}));
}
/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js.
/// These commands don't need a browser, so we handle them directly to avoid
/// sending passwords through the daemon's Unix socket channel.
fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
let exe_path = env::current_exe().unwrap_or_default();
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
#[cfg(windows)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
let mut script_paths = vec![
exe_dir.join("auth-cli.js"),
exe_dir.join("../dist/auth-cli.js"),
PathBuf::from("dist/auth-cli.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
script_paths.insert(0, home_path.join("dist/auth-cli.js"));
script_paths.insert(1, home_path.join("auth-cli.js"));
}
let script_path = match script_paths.iter().find(|p| p.exists()) {
Some(p) => p.clone(),
None => {
if json_mode {
print_json_error("auth-cli.js not found");
} else {
eprintln!(
"{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.",
color::error_indicator()
);
}
exit(1);
}
};
let cmd_json = serde_json::to_string(cmd).unwrap_or_default();
match ProcessCommand::new("node")
.arg(&script_path)
.arg(&cmd_json)
.output()
{
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprint!("{}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
if json_mode {
print_json_error("No response from auth-cli");
} else {
eprintln!("{} No response from auth-cli", color::error_indicator());
}
exit(1);
}
if json_mode {
println!("{}", stdout);
} else {
// Parse the JSON response and use the standard output formatter
match serde_json::from_str::<connection::Response>(stdout) {
Ok(resp) => {
let action = cmd.get("action").and_then(|v| v.as_str());
let opts = OutputOptions {
json: false,
content_boundaries: false,
max_output: None,
};
print_response_with_opts(&resp, action, &opts);
if !resp.success {
exit(1);
}
}
Err(_) => {
println!("{}", stdout);
}
}
}
exit(output.status.code().unwrap_or(0));
}
Err(e) => {
if json_mode {
print_json_error(format!("Failed to run auth-cli: {}", e));
} else {
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
let Some(protocol_end) = proxy_str.find("://") else {
return json!({ "server": proxy_str });
@@ -299,13 +192,9 @@ fn main() {
}
let args: Vec<String> = env::args().skip(1).collect();
let mut flags = parse_flags(&args);
let flags = parse_flags(&args);
let clean = clean_args(&args);
if flags.engine.is_some() && !flags.native {
flags.native = true;
}
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
@@ -392,17 +281,6 @@ fn main() {
}
}
// Handle local auth commands without starting the daemon.
// These don't need a browser, so we avoid sending passwords through the socket.
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
if matches!(
action,
"auth_save" | "auth_list" | "auth_show" | "auth_delete"
) {
run_auth_cli(&cmd, flags.json);
}
}
// Validate session name before starting daemon
if let Some(ref name) = flags.session_name {
if !validation::is_valid_session_name(name) {
@@ -436,7 +314,6 @@ fn main() {
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
native: flags.native,
engine: flags.engine.as_deref(),
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
@@ -495,7 +372,6 @@ fn main() {
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"),
flags.cli_native.then_some("--native"),
flags.cli_headed.then_some("--headed"),
]
.into_iter()
+84 -1
View File
@@ -1,10 +1,12 @@
use serde_json::{json, Value};
use std::env;
use tokio::sync::broadcast;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use super::auth;
use super::browser::{BrowserManager, WaitUntil};
use super::cdp::chrome::LaunchOptions;
use super::cdp::client::CdpClient;
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent,
@@ -102,6 +104,8 @@ pub struct DaemonState {
pub tracked_requests: Vec<TrackedRequest>,
pub request_tracking: bool,
pub active_frame_id: Option<String>,
/// Shared slot for stream server to receive CDP client when browser launches.
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
}
impl DaemonState {
@@ -134,15 +138,33 @@ impl DaemonState {
tracked_requests: Vec::new(),
request_tracking: false,
active_frame_id: None,
stream_client: None,
}
}
/// Create state with an optional stream client slot (for daemon startup with stream server).
pub fn new_with_stream_client(
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
) -> Self {
let mut s = Self::new();
s.stream_client = stream_client;
s
}
fn subscribe_to_browser_events(&mut self) {
if let Some(ref browser) = self.browser {
self.event_rx = Some(browser.client.subscribe());
}
}
/// Update the stream server's CDP client slot when browser is set or cleared.
pub async fn update_stream_client(&self) {
if let Some(ref slot) = self.stream_client {
let mut guard = slot.write().await;
*guard = self.browser.as_ref().map(|m| Arc::clone(&m.client));
}
}
fn drain_cdp_events(
&mut self,
) -> (
@@ -540,6 +562,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
let _ = mgr.close().await;
}
state.browser = None;
state.update_stream_client().await;
}
if let Err(e) = auto_launch(state).await {
return error_response(&id, &format!("Auto-launch failed: {}", e));
@@ -739,6 +762,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
try_auto_restore_state(state).await;
return Ok(());
}
@@ -747,6 +771,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let mgr = BrowserManager::connect_auto().await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
try_auto_restore_state(state).await;
return Ok(());
}
@@ -754,6 +779,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
try_auto_restore_state(state).await;
Ok(())
}
@@ -857,6 +883,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
if let Some(ref mut b) = state.browser {
b.close().await?;
state.browser = None;
state.update_stream_client().await;
}
} else {
return Ok(json!({ "launched": true, "reused": true }));
@@ -894,18 +921,21 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
if let Some(url) = cdp_url {
state.browser = Some(BrowserManager::connect_cdp(url).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
if let Some(port) = cdp_port {
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
if auto_connect {
state.browser = Some(BrowserManager::connect_auto().await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -923,6 +953,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
Ok(mgr) => {
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true, "provider": provider }));
}
Err(e) => {
@@ -1008,6 +1039,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
if let Some(ref filter) = state.domain_filter {
if let Some(ref mgr) = state.browser {
@@ -1287,6 +1319,7 @@ async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
mgr.close().await?;
}
state.browser = None;
state.update_stream_client().await;
// Close WebDriver sessions
if let Some(ref mut wb) = state.webdriver_backend {
@@ -1463,6 +1496,44 @@ async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let new_tab = cmd.get("newTab").and_then(|v| v.as_bool()).unwrap_or(false);
if new_tab {
use super::element::resolve_element_object_id;
let object_id =
resolve_element_object_id(&mgr.client, &session_id, &state.ref_map, selector).await?;
let call_params = json!({
"objectId": object_id,
"functionDeclaration": "function() { var h = this.getAttribute('href'); if (!h) return null; try { return new URL(h, document.baseURI).toString(); } catch(e) { return null; } }",
"returnByValue": true
});
let call_result = mgr
.client
.send_command(
"Runtime.callFunctionOn",
Some(call_params),
Some(&session_id),
)
.await?;
let href = call_result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
format!(
"Element '{}' does not have an href attribute. --new-tab only works on links.",
selector
)
})?
.to_string();
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
state.ref_map.clear();
mgr.tab_new(Some(&href)).await?;
return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
}
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
@@ -5334,6 +5405,12 @@ mod tests {
#[tokio::test]
async fn test_credentials_roundtrip_via_actions() {
let _lock = crate::native::auth::AUTH_TEST_MUTEX.lock().unwrap();
let key_var = "AGENT_BROWSER_ENCRYPTION_KEY";
let original = std::env::var(key_var).ok();
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
unsafe { std::env::set_var(key_var, "a".repeat(64)) };
let mut state = DaemonState::new();
let set_cmd = json!({
@@ -5366,6 +5443,12 @@ mod tests {
});
let result = execute_command(&del_cmd, &mut state).await;
assert_eq!(result["success"], true);
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
match original {
Some(val) => unsafe { std::env::set_var(key_var, val) },
None => unsafe { std::env::remove_var(key_var) },
}
}
#[tokio::test]
+4 -4
View File
@@ -160,7 +160,7 @@ impl BrowserProcess {
}
pub struct BrowserManager {
pub client: CdpClient,
pub client: Arc<CdpClient>,
browser_process: Option<BrowserProcess>,
ws_url: String,
pages: Vec<PageInfo>,
@@ -226,7 +226,7 @@ impl BrowserManager {
let manager = if engine == "lightpanda" {
initialize_lightpanda_manager(ws_url, process).await?
} else {
let client = CdpClient::connect(&ws_url).await?;
let client = Arc::new(CdpClient::connect(&ws_url).await?);
let mut manager = Self {
client,
browser_process: Some(process),
@@ -290,7 +290,7 @@ impl BrowserManager {
pub async fn connect_cdp(url: &str) -> Result<Self, String> {
let ws_url = resolve_cdp_url(url).await?;
let client = CdpClient::connect(&ws_url).await?;
let client = Arc::new(CdpClient::connect(&ws_url).await?);
let mut manager = Self {
client,
browser_process: None,
@@ -1173,7 +1173,7 @@ async fn initialize_lightpanda_manager(
};
let mut manager = BrowserManager {
client,
client: Arc::new(client),
browser_process: None,
ws_url: ws_url.clone(),
pages: Vec::new(),
+13 -10
View File
@@ -190,7 +190,7 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => {
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
find_chrome().ok_or("Chrome not found. Run `agent-browser install` to download Chrome, or use --executable-path.")?
}
};
@@ -320,6 +320,12 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
}
pub fn find_chrome() -> Option<PathBuf> {
// 1. Check Chrome downloaded by `agent-browser install`
if let Some(p) = crate::install::find_installed_chrome() {
return Some(p);
}
// 2. Check system-installed Chrome
#[cfg(target_os = "macos")]
{
let candidates = [
@@ -333,10 +339,6 @@ pub fn find_chrome() -> Option<PathBuf> {
return Some(p);
}
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
}
#[cfg(target_os = "linux")]
@@ -357,10 +359,6 @@ pub fn find_chrome() -> Option<PathBuf> {
}
}
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
}
#[cfg(target_os = "windows")]
@@ -383,6 +381,11 @@ pub fn find_chrome() -> Option<PathBuf> {
}
}
// 3. Fallback: check Playwright's browser cache (for existing installs)
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
None
}
@@ -500,7 +503,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
}
/// Search Playwright's browser cache for a Chromium binary.
/// This is where `agent-browser install` (via `npx playwright install chromium`) puts it.
/// Legacy fallback for users who previously installed Chromium via Playwright.
fn find_playwright_chromium() -> Option<PathBuf> {
let mut search_dirs = Vec::new();
+3
View File
@@ -342,6 +342,7 @@ mod tests {
socket.write_all(response.as_bytes()).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn waits_for_ready_without_logs() {
let port = unused_port();
@@ -369,6 +370,7 @@ mod tests {
let _ = child.wait();
}
#[cfg(unix)]
#[tokio::test]
async fn child_exit_surfaces_logs() {
let port = unused_port();
@@ -389,6 +391,7 @@ mod tests {
assert!(err.contains("boom"));
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_reports_last_probe_error() {
let port = unused_port();
+109 -11
View File
@@ -3,12 +3,17 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::signal;
use tokio::sync::{mpsc, RwLock};
use super::actions::{execute_command, DaemonState};
use super::cdp::client::CdpClient;
use super::state;
use super::stream::StreamServer;
pub async fn run_daemon(session: &str) {
let socket_dir = get_daemon_socket_dir();
@@ -33,7 +38,34 @@ pub async fn run_daemon(session: &str) {
}
}
let result = run_socket_server(&socket_path, session).await;
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") {
if let Ok(port) = port_str.parse::<u16>() {
if port > 0 {
match StreamServer::start_without_client(port, session.to_string()).await {
Ok((stream_server, client_slot)) => {
stream_client = Some(client_slot.clone());
let stream_path = socket_dir.join(format!("{}.stream", session));
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
eprintln!("Failed to write .stream file: {}", e);
}
}
Err(e) => {
eprintln!("Stream server failed to start: {}", e);
}
}
}
}
}
// Auto-shutdown the daemon after this many ms of inactivity (no commands received).
// Disabled when unset or 0.
let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&ms| ms > 0);
let result = run_socket_server(&socket_path, session, stream_client, idle_timeout_ms).await;
let _ = fs::remove_file(&socket_path);
let _ = fs::remove_file(&pid_path);
@@ -47,23 +79,36 @@ pub async fn run_daemon(session: &str) {
}
#[cfg(unix)]
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
_session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::UnixListener;
let listener =
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
tokio::sync::Mutex::new(DaemonState::new_with_stream_client(stream_client)),
);
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
loop {
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
let mut sleep_pin = sleep_future.map(Box::pin);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let reset_tx = reset_tx.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
handle_connection(stream, state, reset_tx).await;
});
}
Err(e) => {
@@ -71,6 +116,22 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
}
}
_ = async {
if let Some(ref mut s) = sleep_pin {
s.as_mut().await
} else {
std::future::pending::<()>().await
}
}, if idle_timeout_ms.is_some() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
break;
}
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
continue;
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
@@ -85,7 +146,12 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
#[cfg(windows)]
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::TcpListener;
let port = get_port_for_session(session);
@@ -97,17 +163,25 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
let port_path = socket_dir.join(format!("{}.port", session));
let _ = fs::write(&port_path, port.to_string());
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
tokio::sync::Mutex::new(DaemonState::new_with_stream_client(stream_client)),
);
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
loop {
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
let mut sleep_pin = sleep_future.map(Box::pin);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let reset_tx = reset_tx.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
handle_connection(stream, state, reset_tx).await;
});
}
Err(e) => {
@@ -115,6 +189,23 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
}
}
}
_ = async {
if let Some(ref mut s) = sleep_pin {
s.as_mut().await
} else {
std::future::pending::<()>().await
}
}, if idle_timeout_ms.is_some() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
let _ = fs::remove_file(&port_path);
break;
}
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
continue;
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
@@ -129,8 +220,11 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
Ok(())
}
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
where
async fn handle_connection<S>(
stream: S,
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
@@ -165,6 +259,10 @@ where
}
};
if let Some(ref tx) = idle_reset_tx {
let _ = tx.try_send(());
}
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let response = {
+1
View File
@@ -533,6 +533,7 @@ async fn test_daemon_state_new_defaults() {
assert!(state.tracked_requests.is_empty());
assert!(state.active_frame_id.is_none());
assert!(state.webdriver_backend.is_none());
assert!(state.stream_client.is_none());
}
#[tokio::test]
+37 -10
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, Mutex};
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio_tungstenite::tungstenite::Message;
use super::cdp::client::CdpClient;
@@ -47,6 +47,27 @@ impl StreamServer {
client: Arc<CdpClient>,
session_id: String,
) -> Result<Self, String> {
let client_slot = Arc::new(RwLock::new(Some(client)));
let (server, _) = Self::start_inner(preferred_port, client_slot, session_id).await?;
Ok(server)
}
/// Start the stream server without a CDP client (e.g. at daemon startup before browser launch).
/// Returns the server and a shared slot to set the client when the browser launches.
/// Input messages are ignored until the client is set.
pub async fn start_without_client(
preferred_port: u16,
session_id: String,
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), String> {
let client_slot = Arc::new(RwLock::new(None::<Arc<CdpClient>>));
Self::start_inner(preferred_port, client_slot, session_id).await
}
async fn start_inner(
preferred_port: u16,
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
session_id: String,
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), String> {
let addr = format!("127.0.0.1:{}", preferred_port);
let listener = TcpListener::bind(&addr)
.await
@@ -62,23 +83,27 @@ impl StreamServer {
let frame_tx_clone = frame_tx.clone();
let client_count_clone = client_count.clone();
let client_slot_clone = client_slot.clone();
tokio::spawn(async move {
accept_loop(
listener,
frame_tx_clone,
client_count_clone,
client,
client_slot_clone,
session_id,
)
.await;
});
Ok(Self {
Ok((
Self {
port,
frame_tx,
client_count,
})
},
client_slot,
))
}
pub fn port(&self) -> u16 {
@@ -140,17 +165,17 @@ async fn accept_loop(
listener: TcpListener,
frame_tx: broadcast::Sender<String>,
client_count: Arc<Mutex<usize>>,
cdp_client: Arc<CdpClient>,
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
session_id: String,
) {
while let Ok((stream, addr)) = listener.accept().await {
let frame_rx = frame_tx.subscribe();
let client_count = client_count.clone();
let cdp = cdp_client.clone();
let client_slot = client_slot.clone();
let sid = session_id.clone();
tokio::spawn(async move {
handle_ws_client(stream, addr, frame_rx, client_count, cdp, sid).await;
handle_ws_client(stream, addr, frame_rx, client_count, client_slot, sid).await;
});
}
}
@@ -161,10 +186,9 @@ async fn handle_ws_client(
_addr: SocketAddr,
mut frame_rx: broadcast::Receiver<String>,
client_count: Arc<Mutex<usize>>,
cdp_client: Arc<CdpClient>,
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
session_id: String,
) {
// Origin checking on WebSocket handshake
let callback =
|req: &tokio_tungstenite::tungstenite::handshake::server::Request,
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
@@ -211,7 +235,10 @@ async fn handle_ws_client(
msg = ws_rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
handle_client_message(&text, &cdp_client, &session_id).await;
let guard = client_slot.read().await;
if let Some(ref client) = *guard {
handle_client_message(&text, client.as_ref(), &session_id).await;
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
+9 -14
View File
@@ -1389,8 +1389,7 @@ Options:
Each label [N] corresponds to ref @eN from snapshot.
Prints a legend mapping labels to element roles/names.
With --json, annotations are included in the response.
In native mode, this is currently supported on the
CDP-backed browser path (Chromium/Lightpanda).
Supported on Chromium and Lightpanda.
--screenshot-dir <path> Default output directory for screenshots
(or AGENT_BROWSER_SCREENSHOT_DIR env)
--screenshot-quality <0-100> JPEG quality (0-100, only applies to jpeg format)
@@ -1986,7 +1985,7 @@ agent-browser trace - Record execution trace
Usage: agent-browser trace <operation> [path]
Record a trace for debugging with Playwright Trace Viewer.
Record a Chrome DevTools trace for debugging.
Operations:
start [path] Start recording trace
@@ -2053,7 +2052,7 @@ Usage: agent-browser record start <path.webm> [url]
agent-browser record stop
agent-browser record restart <path.webm> [url]
Record the browser to a WebM video file using Playwright's native recording.
Record the browser to a WebM video file.
Creates a fresh browser context but preserves cookies and localStorage.
If no URL is provided, automatically navigates to your current page.
@@ -2499,7 +2498,7 @@ Diff:
diff url <u1> <u2> Compare two pages
Debug:
trace start|stop [path] Record Playwright trace
trace start|stop [path] Record Chrome DevTools trace
profiler start|stop [path] Record Chrome DevTools profile
record start <path> [url] Start video recording (WebM)
record stop Stop and save video
@@ -2576,8 +2575,7 @@ Options:
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--engine <name> Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE)
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
--engine <name> Browser engine: chrome (default), lightpanda (or AGENT_BROWSER_ENGINE)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
@@ -2620,11 +2618,12 @@ Environment:
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default)
AGENT_BROWSER_IOS_DEVICE Default iOS device name
AGENT_BROWSER_IOS_UDID Default iOS device UDID
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
@@ -2634,17 +2633,13 @@ Environment:
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
AGENT_BROWSER_SCREENSHOT_DIR Default screenshot output directory
AGENT_BROWSER_SCREENSHOT_QUALITY JPEG quality 0-100
AGENT_BROWSER_SCREENSHOT_FORMAT Screenshot format: png, jpeg
Install (recommended, fastest - native Rust CLI):
Install:
npm install -g agent-browser
agent-browser install # Download Chromium (first time)
Try without installing (slower, routes through Node.js):
npx agent-browser open example.com
agent-browser install # Download Chrome (first time)
Examples:
agent-browser open example.com
+1 -1
View File
@@ -62,7 +62,7 @@ This is useful when:
## Color scheme
Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference:
Use `--color-scheme` to set a persistent preference when connecting via CDP:
```bash
agent-browser --cdp 9222 --color-scheme dark open https://example.com
+2 -2
View File
@@ -50,7 +50,7 @@ agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
### New Features
- **`inspect` command** -- Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Agent commands continue to work while DevTools is open. Implemented in both Node.js and native daemon paths.
- **`inspect` command** -- Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Agent commands continue to work while DevTools is open.
```bash
agent-browser open example.com
@@ -64,7 +64,7 @@ agent-browser click "Submit" # commands still work while DevTools is open
agent-browser get cdp-url
```
- **Native screenshot annotate** -- The `--annotate` flag for screenshots now works in the native Rust daemon, bringing full parity with the Node.js path.
- **Screenshot annotate** -- The `--annotate` flag overlays numbered labels on interactive elements in screenshots.
### Improvements
+4 -5
View File
@@ -81,7 +81,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
<tr><td><code>engine</code></td><td><code>--engine</code></td><td>string (<code>chrome</code>, <code>lightpanda</code>)</td></tr>
<tr><td><code>native</code></td><td><code>--native</code></td><td>boolean (experimental)</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
</tbody>
</table>
@@ -151,7 +150,7 @@ agent-browser --headed open example.com # same as --headed true
agent-browser --headed true open example.com # explicit
```
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`, `--native`.
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`.
## Extensions Merging
@@ -172,13 +171,14 @@ These environment variables configure additional daemon and runtime behavior:
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_EXTENSIONS</code></td><td>Comma-separated browser extension paths. Extensions work in both headed and headless mode.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_HEADED</code></td><td>Show browser window instead of running headless (<code>1</code> to enable).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_IDLE_TIMEOUT_MS</code></td><td>Auto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
@@ -188,8 +188,7 @@ These environment variables configure additional daemon and runtime behavior:
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>. Implies <code>--native</code>.</td><td><code>chrome</code></td></tr>
<tr><td><code>AGENT_BROWSER_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>.</td><td><code>chrome</code></td></tr>
</tbody>
</table>
+3 -3
View File
@@ -21,7 +21,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th
<code>/Applications/Google Chrome.app</code>,
<code>/Applications/Google Chrome Canary.app</code>,
<code>/Applications/Chromium.app</code>,
Playwright Chromium cache
Chrome for Testing cache
</td>
</tr>
<tr>
@@ -31,7 +31,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th
<code>google-chrome-stable</code>,
<code>chromium-browser</code>,
<code>chromium</code> in PATH,
Playwright Chromium cache
Chrome for Testing cache
</td>
</tr>
<tr>
@@ -45,7 +45,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th
</tbody>
</table>
If Chrome is not found, run `agent-browser install` to download Chromium via Playwright.
If Chrome is not found, run `agent-browser install` to download Chrome from Chrome for Testing.
## Usage
+6 -27
View File
@@ -10,42 +10,34 @@ Installs the native Rust binary for maximum performance:
```bash
npm install -g agent-browser
agent-browser install # Download Chromium
agent-browser install # Download Chrome from Chrome for Testing (first time)
```
This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead.
## Quick start (no install)
Run directly with `npx` if you want to try it without installing globally:
```bash
npx agent-browser install # Download Chromium (first time only)
npx agent-browser install # Download Chrome (first time only)
npx agent-browser open example.com
```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
## Project installation (local dependency)
For projects that want to pin the version in `package.json`:
```bash
npm install agent-browser
npx agent-browser install
npx agent-browser install # Download Chrome (first time)
```
Then use via `npx` or `package.json` scripts:
```bash
npx agent-browser open example.com
```
Then use via `npx` or `package.json` scripts.
## Homebrew (macOS)
```bash
brew install agent-browser
agent-browser install # Download Chromium
agent-browser install # Download Chrome (first time)
```
## From source
@@ -66,7 +58,6 @@ On Linux, install system dependencies:
```bash
agent-browser install --with-deps
# or manually: npx playwright install-deps chromium
```
## Custom browser
@@ -87,19 +78,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
### Serverless example
```typescript
import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser';
export async function handler() {
const browser = new BrowserManager();
await browser.launch({
executablePath: await chromium.executablePath(),
headless: true,
});
// ... use browser
}
```
Use `@sparticuz/chromium` or similar to obtain a Chromium executable path, then pass it via `--executable-path` or `AGENT_BROWSER_EXECUTABLE_PATH`.
## AI agent setup
+3 -80
View File
@@ -2,85 +2,8 @@ import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("native-mode")
# Native Mode (Experimental)
# Native Mode
agent-browser includes an experimental native Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating the Node.js and Playwright dependencies entirely.
agent-browser is now 100% native Rust by default. The Node.js/Playwright daemon has been removed.
## Enabling Native Mode
Native mode is opt-in. Enable it with the `--native` flag or the `AGENT_BROWSER_NATIVE` environment variable.
### CLI Flag
```bash
agent-browser --native open example.com
agent-browser --native snapshot
agent-browser --native close
```
### Environment Variable
Set `AGENT_BROWSER_NATIVE=1` to avoid passing the flag on every command:
```bash
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
agent-browser snapshot
agent-browser close
```
### Config File
Add `"native": true` to your `agent-browser.json`:
```json
{"native": true}
```
## Architecture Comparison
<table>
<thead>
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
</thead>
<tbody>
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
</tbody>
</table>
## What Works
All core commands are supported in native mode:
- Navigation: `open`, `back`, `forward`, `reload`
- Interaction: `click`, `fill`, `type`, `press`, `hover`, `select`, `check`, `uncheck`, `scroll`, `focus`, `clear`, `upload`, `drag`
- Observation: `snapshot`, `screenshot`, `eval`, `get text/html/value/attr/count/box/styles`, `is visible/enabled/checked`
- State: `cookies get/set/clear`, `storage local/session`, `state save/load/list`
- Tabs: `tab new/list/close`, tab switching
- Emulation: `set viewport`, `set device`, `set geo`, user agent, timezone, locale
- Streaming: WebSocket screencast and remote input
- Diffing: `diff snapshot`, `diff url`
- Recording: `record start/stop`
- Profiling: `profiler start/stop`, `trace start/stop`
## Known Limitations
- **Firefox and WebKit** are not yet supported (Chromium and Safari only)
- **Annotated screenshots** (`screenshot --annotate`) currently work on the CDP-backed browser path. The Safari/WebDriver backend does not yet support them.
- **Playwright trace format** is not available (native tracing uses Chrome's built-in tracing)
- **HAR export** is not available
- **Network route interception** uses CDP Fetch domain instead of Playwright's route API
## Switching Between Modes
The native daemon and Node.js daemon share the same session socket. You cannot run both simultaneously for the same session. Close the current daemon before switching:
```bash
agent-browser close
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
This page is no longer relevant. See the main [documentation](/) for current architecture and usage.
+4 -4
View File
@@ -4,11 +4,12 @@ export const metadata = pageMetadata("")
# agent-browser
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. 100% native Rust.
```bash
npm install -g agent-browser # all platforms (fastest, native Rust CLI)
npm install -g agent-browser # all platforms
brew install agent-browser # macOS
agent-browser install # Download Chrome (first time)
# or try without installing
npx agent-browser open example.com
@@ -59,8 +60,7 @@ has a unique ref like `@e1`, `@e2`. This provides:
Client-daemon architecture for optimal performance:
1. **Rust CLI** - Parses commands, communicates with daemon
2. **Node.js Daemon** (default) - Manages Playwright browser instance
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
2. **Native Daemon** - Pure Rust daemon using direct CDP, manages Chrome via Chrome DevTools Protocol
Daemon starts automatically and persists between commands.
+1 -1
View File
@@ -38,7 +38,7 @@ export const navigation: NavSection[] = [
{ name: "iOS Simulator", href: "/ios" },
{ name: "Security", href: "/security" },
{ name: "Next.js + Vercel", href: "/next" },
{ name: "Native Mode (Experimental)", href: "/native-mode" },
{ name: "Native Mode", href: "/native-mode" },
],
},
{
+1 -1
View File
@@ -17,7 +17,7 @@ export const PAGE_TITLES: Record<string, string> = {
"engines/chrome": "Chrome",
"engines/lightpanda": "Lightpanda",
next: "Next.js + Vercel",
"native-mode": "Native Mode (Experimental)",
"native-mode": "Native Mode",
changelog: "Changelog",
};
+5 -49
View File
@@ -3,18 +3,7 @@
"version": "0.19.0",
"description": "Headless browser automation CLI for AI agents",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"bin",
"scripts",
"skills"
@@ -23,39 +12,26 @@
"agent-browser": "./bin/agent-browser.js"
},
"scripts": {
"prepare": "husky",
"version:sync": "node scripts/sync-version.js",
"version": "npm run version:sync && git add cli/Cargo.toml",
"build": "tsc",
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
"start": "node dist/daemon.js",
"dev": "tsx src/daemon.ts",
"typecheck": "tsc --noEmit",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
"bench": "pnpm build && tsx test/benchmarks/run.ts",
"bench:node": "pnpm build && tsx test/benchmarks/run.ts --node-only",
"bench:native": "pnpm build && tsx test/benchmarks/run.ts --native-only",
"bench:engine": "pnpm build && tsx test/benchmarks/run.ts --engine",
"release": "npm run version:sync && npm run build:all-platforms && npm publish",
"postinstall": "node scripts/postinstall.js",
"changeset": "changeset",
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
"ci:publish": "pnpm run version:sync && changeset publish"
},
"keywords": [
"browser",
"automation",
"headless",
"playwright",
"chrome",
"cdp",
"cli",
"agent"
],
@@ -68,27 +44,7 @@
"url": "https://github.com/vercel-labs/agent-browser/issues"
},
"homepage": "https://github.com/vercel-labs/agent-browser#readme",
"dependencies": {
"node-simctl": "^7.4.0",
"playwright-core": "^1.57.0",
"webdriverio": "^9.15.0",
"ws": "^8.19.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.52",
"@changesets/cli": "^2.29.8",
"@types/node": "^20.10.0",
"@types/ws": "^8.18.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"playwright": "^1.57.0",
"prettier": "^3.7.4",
"tsx": "^4.6.0",
"typescript": "^5.3.0",
"vitest": "^4.0.16"
},
"lint-staged": {
"src/**/*.ts": "prettier --write"
"@changesets/cli": "^2.29.8"
}
}
+12 -15
View File
@@ -80,7 +80,7 @@ async function main() {
// On global installs, fix npm's bin entry to use native binary directly
await fixGlobalInstallBin();
showPlaywrightReminder();
showInstallReminder();
return;
}
@@ -102,8 +102,7 @@ async function main() {
console.log(`✓ Downloaded native binary: ${binaryName}`);
} catch (err) {
console.log(`Could not download native binary: ${err.message}`);
console.log(` The CLI will use Node.js fallback (slightly slower startup)`);
console.log(`Could not download native binary: ${err.message}`);
console.log('');
console.log('To build the native binary locally:');
console.log(' 1. Install Rust: https://rustup.rs');
@@ -114,21 +113,19 @@ async function main() {
// This avoids the /bin/sh error on Windows and provides zero-overhead execution
await fixGlobalInstallBin();
showPlaywrightReminder();
showInstallReminder();
}
function showPlaywrightReminder() {
function showInstallReminder() {
console.log('');
console.log(' To download Chrome, run:');
console.log('');
console.log(' agent-browser install');
console.log('');
console.log(' On Linux, include system dependencies with:');
console.log('');
console.log(' agent-browser install --with-deps');
console.log('');
console.log('╔═══════════════════════════════════════════════════════════════════════════╗');
console.log('║ To download browser binaries, run: ║');
console.log('║ ║');
console.log('║ npx playwright install chromium ║');
console.log('║ ║');
console.log('║ On Linux, include system dependencies with: ║');
console.log('║ ║');
console.log('║ npx playwright install --with-deps chromium ║');
console.log('║ ║');
console.log('╚═══════════════════════════════════════════════════════════════════════════╝');
}
/**
+9 -18
View File
@@ -6,6 +6,8 @@ allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
# Browser Automation with agent-browser
The CLI uses Chrome/Chromium via CDP directly. Run `agent-browser install` to download Chrome.
## Core Workflow
Every browser automation follows this pattern:
@@ -441,7 +443,7 @@ agent-browser diff url https://staging.example.com https://prod.example.com --sc
## Timeouts and Slow Pages
The default Playwright timeout is 25 seconds for local browsers. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout:
The default timeout is 25 seconds. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout:
```bash
# Wait for network activity to settle (best for slow pages)
@@ -485,6 +487,12 @@ agent-browser --session agent1 close # Close specific session
If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work.
To auto-shutdown the daemon after a period of inactivity (useful for ephemeral/CI environments):
```bash
AGENT_BROWSER_IDLE_TIMEOUT_MS=60000 agent-browser open example.com
```
## Ref Lifecycle (Important)
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after:
@@ -503,8 +511,6 @@ agent-browser click @e1 # Use new refs
Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot.
In native mode, this currently works on the CDP-backed browser path (Chromium/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`.
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
@@ -589,21 +595,6 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
## Experimental: Native Mode
agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet.
```bash
# Enable via flag
agent-browser --native open example.com
# Enable via environment variable (avoids passing --native every time)
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session.
## Browser Engine Selection
Use `--engine` to choose a local browser engine. The default is `chrome`.
+5 -5
View File
@@ -104,11 +104,11 @@ agent-browser tab --url "*settings*"
## Webview Support
Electron `<webview>` elements are automatically discovered and can be controlled like regular pages. When using `--native` mode, webviews appear as separate targets in the tab list with `type: "webview"`:
Electron `<webview>` elements are automatically discovered and can be controlled like regular pages. Webviews appear as separate targets in the tab list with `type: "webview"`:
```bash
# Connect in native mode
agent-browser --native connect 9222
# Connect to running Electron app
agent-browser connect 9222
# List targets -- webviews appear alongside pages
agent-browser tab
@@ -125,7 +125,7 @@ agent-browser click @e3
agent-browser screenshot webview.png
```
**Note:** Webview support requires `--native` mode (raw CDP). The Playwright-based mode does not support webview targets.
**Note:** Webview support works via raw CDP connection.
## Common Patterns
@@ -188,7 +188,7 @@ agent-browser --session vscode snapshot -i
## Color Scheme
Playwright overrides the color scheme to `light` by default when connecting via CDP. To preserve dark mode:
The default color scheme when connecting via CDP may be `light`. To preserve dark mode:
```bash
agent-browser connect 9222
-213
View File
@@ -1,213 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
getActionCategory,
checkPolicy,
loadPolicyFile,
describeAction,
KNOWN_CATEGORIES,
type ActionPolicy,
} from './action-policy.js';
describe('action-policy', () => {
describe('getActionCategory', () => {
it('should return correct category for known actions', () => {
expect(getActionCategory('navigate')).toBe('navigate');
expect(getActionCategory('click')).toBe('click');
expect(getActionCategory('fill')).toBe('fill');
expect(getActionCategory('evaluate')).toBe('eval');
expect(getActionCategory('download')).toBe('download');
expect(getActionCategory('upload')).toBe('upload');
expect(getActionCategory('snapshot')).toBe('snapshot');
expect(getActionCategory('scroll')).toBe('scroll');
expect(getActionCategory('wait')).toBe('wait');
expect(getActionCategory('gettext')).toBe('get');
expect(getActionCategory('route')).toBe('network');
expect(getActionCategory('state_save')).toBe('state');
expect(getActionCategory('hover')).toBe('interact');
});
it('should return _internal for internal actions', () => {
expect(getActionCategory('launch')).toBe('_internal');
expect(getActionCategory('close')).toBe('_internal');
expect(getActionCategory('session')).toBe('_internal');
expect(getActionCategory('auth_save')).toBe('_internal');
expect(getActionCategory('confirm')).toBe('_internal');
});
it('should return eval for security-sensitive actions', () => {
expect(getActionCategory('setcontent')).toBe('eval');
expect(getActionCategory('expose')).toBe('eval');
expect(getActionCategory('addstyle')).toBe('eval');
});
it('should return unknown for unrecognized actions', () => {
expect(getActionCategory('nonexistent')).toBe('unknown');
expect(getActionCategory('')).toBe('unknown');
});
it('should return get for semantic locator actions', () => {
expect(getActionCategory('getbyrole')).toBe('get');
expect(getActionCategory('getbytext')).toBe('get');
expect(getActionCategory('getbylabel')).toBe('get');
});
});
describe('checkPolicy', () => {
it('should always allow internal actions regardless of policy', () => {
const denyAll: ActionPolicy = { default: 'deny' };
expect(checkPolicy('launch', denyAll, new Set())).toBe('allow');
expect(checkPolicy('close', denyAll, new Set())).toBe('allow');
expect(checkPolicy('session', denyAll, new Set())).toBe('allow');
});
it('should allow all when no policy and no confirm categories', () => {
expect(checkPolicy('navigate', null, new Set())).toBe('allow');
expect(checkPolicy('click', null, new Set())).toBe('allow');
expect(checkPolicy('evaluate', null, new Set())).toBe('allow');
});
it('should deny actions in explicit deny list', () => {
const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] };
expect(checkPolicy('evaluate', policy, new Set())).toBe('deny');
expect(checkPolicy('download', policy, new Set())).toBe('deny');
expect(checkPolicy('click', policy, new Set())).toBe('allow');
});
it('should allow actions in explicit allow list with deny default', () => {
const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] };
expect(checkPolicy('navigate', policy, new Set())).toBe('allow');
expect(checkPolicy('snapshot', policy, new Set())).toBe('allow');
expect(checkPolicy('click', policy, new Set())).toBe('deny');
});
it('should return confirm for actions in confirm categories', () => {
expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm');
expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm');
});
it('should deny over confirm when action is in deny list', () => {
const policy: ActionPolicy = { default: 'allow', deny: ['eval'] };
expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny');
});
it('should use default policy for unknown categories', () => {
const denyPolicy: ActionPolicy = { default: 'deny' };
const allowPolicy: ActionPolicy = { default: 'allow' };
expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny');
expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow');
});
});
describe('loadPolicyFile', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should load a valid allow-default policy', () => {
const policyPath = path.join(tempDir, 'policy.json');
fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] }));
const policy = loadPolicyFile(policyPath);
expect(policy.default).toBe('allow');
expect(policy.deny).toEqual(['eval']);
});
it('should load a valid deny-default policy', () => {
const policyPath = path.join(tempDir, 'policy.json');
fs.writeFileSync(
policyPath,
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] })
);
const policy = loadPolicyFile(policyPath);
expect(policy.default).toBe('deny');
expect(policy.allow).toEqual(['navigate', 'snapshot']);
});
it('should throw on invalid default value', () => {
const policyPath = path.join(tempDir, 'policy.json');
fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' }));
expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"');
});
it('should throw on missing file', () => {
expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow();
});
it('should throw on invalid JSON', () => {
const policyPath = path.join(tempDir, 'policy.json');
fs.writeFileSync(policyPath, 'not json');
expect(() => loadPolicyFile(policyPath)).toThrow();
});
it('should warn on unrecognized category names', () => {
const policyPath = path.join(tempDir, 'policy.json');
fs.writeFileSync(
policyPath,
JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] })
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const policy = loadPolicyFile(policyPath);
expect(policy.default).toBe('allow');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('unrecognized action category "typo_category"')
);
warnSpy.mockRestore();
});
it('should not warn on valid category names', () => {
const policyPath = path.join(tempDir, 'policy.json');
fs.writeFileSync(
policyPath,
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] })
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadPolicyFile(policyPath);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('describeAction', () => {
it('should describe navigate actions', () => {
expect(describeAction('navigate', { url: 'https://example.com' })).toBe(
'Navigate to https://example.com'
);
});
it('should describe eval actions with truncation', () => {
const longScript = 'a'.repeat(200);
const desc = describeAction('evaluate', { script: longScript });
expect(desc).toContain('Evaluate JavaScript:');
expect(desc.length).toBeLessThan(200);
});
it('should describe click actions', () => {
expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn');
});
it('should describe dblclick actions', () => {
expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn');
});
it('should describe tap actions', () => {
expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn');
});
it('should describe fill actions', () => {
expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input');
});
it('should use fallback for unknown actions', () => {
const desc = describeAction('scroll', {});
expect(desc).toContain('scroll');
});
});
});
-297
View File
@@ -1,297 +0,0 @@
import { readFileSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
export interface ActionPolicy {
default: 'allow' | 'deny';
allow?: string[];
deny?: string[];
}
export type PolicyDecision = 'allow' | 'deny' | 'confirm';
const ACTION_CATEGORIES: Record<string, string> = {
navigate: 'navigate',
back: 'navigate',
forward: 'navigate',
reload: 'navigate',
tab_new: 'navigate',
click: 'click',
dblclick: 'click',
tap: 'click',
fill: 'fill',
type: 'fill',
// The `keyboard` action is a compound command that dispatches to sub-actions
// (type, inserttext, press, down, up). Its primary use is text input, so it
// maps to 'fill'. The interact-like sub-actions (press, down, up) are less
// common and don't have separate top-level action names in the protocol.
keyboard: 'fill',
inserttext: 'fill',
select: 'fill',
multiselect: 'fill',
check: 'fill',
uncheck: 'fill',
clear: 'fill',
selectall: 'fill',
setvalue: 'fill',
download: 'download',
waitfordownload: 'download',
upload: 'upload',
evaluate: 'eval',
evalhandle: 'eval',
addscript: 'eval',
addinitscript: 'eval',
snapshot: 'snapshot',
screenshot: 'snapshot',
pdf: 'snapshot',
diff_snapshot: 'snapshot',
diff_screenshot: 'snapshot',
diff_url: 'snapshot',
scroll: 'scroll',
scrollintoview: 'scroll',
wait: 'wait',
waitforurl: 'wait',
waitforloadstate: 'wait',
waitforfunction: 'wait',
gettext: 'get',
content: 'get',
innerhtml: 'get',
innertext: 'get',
inputvalue: 'get',
url: 'get',
title: 'get',
getattribute: 'get',
count: 'get',
boundingbox: 'get',
styles: 'get',
isvisible: 'get',
isenabled: 'get',
ischecked: 'get',
responsebody: 'get',
route: 'network',
unroute: 'network',
requests: 'network',
state_save: 'state',
state_load: 'state',
cookies_set: 'state',
storage_set: 'state',
credentials: 'state',
hover: 'interact',
focus: 'interact',
drag: 'interact',
press: 'interact',
keydown: 'interact',
keyup: 'interact',
mousemove: 'interact',
mousedown: 'interact',
mouseup: 'interact',
wheel: 'interact',
dispatch: 'interact',
// These are always allowed (internal/meta operations)
launch: '_internal',
close: '_internal',
tab_list: '_internal',
tab_switch: '_internal',
tab_close: '_internal',
window_new: '_internal',
frame: '_internal',
mainframe: '_internal',
dialog: '_internal',
session: '_internal',
console: '_internal',
errors: '_internal',
cookies_get: '_internal',
cookies_clear: '_internal',
storage_get: '_internal',
storage_clear: '_internal',
state_list: '_internal',
state_show: '_internal',
state_clear: '_internal',
state_clean: '_internal',
state_rename: '_internal',
highlight: '_internal',
bringtofront: '_internal',
trace_start: '_internal',
trace_stop: '_internal',
har_start: '_internal',
har_stop: '_internal',
video_start: '_internal',
video_stop: '_internal',
recording_start: '_internal',
recording_stop: '_internal',
recording_restart: '_internal',
profiler_start: '_internal',
profiler_stop: '_internal',
clipboard: '_internal',
viewport: '_internal',
useragent: '_internal',
device: '_internal',
geolocation: '_internal',
permissions: '_internal',
emulatemedia: '_internal',
offline: '_internal',
headers: '_internal',
addstyle: 'eval',
expose: 'eval',
timezone: '_internal',
locale: '_internal',
pause: '_internal',
setcontent: 'eval',
screencast_start: '_internal',
screencast_stop: '_internal',
input_mouse: '_internal',
input_keyboard: '_internal',
input_touch: '_internal',
auth_save: '_internal',
auth_login: '_internal',
auth_list: '_internal',
auth_delete: '_internal',
auth_show: '_internal',
confirm: '_internal',
deny: '_internal',
// Find/semantic locator actions (read-only element resolution)
getbyrole: 'get',
getbytext: 'get',
getbylabel: 'get',
getbyplaceholder: 'get',
getbyalttext: 'get',
getbytitle: 'get',
getbytestid: 'get',
nth: 'get',
};
// User-facing categories used in policy files. '_internal' is excluded because
// internal actions always bypass policy. 'unknown' is intentionally not a value
// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory()
// for unrecognized actions. If a user puts "unknown" in a policy file,
// loadPolicyFile will warn about it as unrecognized, which is correct.
export const KNOWN_CATEGORIES = new Set(
Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal')
);
export function getActionCategory(action: string): string {
return ACTION_CATEGORIES[action] ?? 'unknown';
}
export function loadPolicyFile(policyPath: string): ActionPolicy {
const resolved = resolve(policyPath);
const content = readFileSync(resolved, 'utf-8');
const policy = JSON.parse(content) as ActionPolicy;
if (policy.default !== 'allow' && policy.default !== 'deny') {
throw new Error(
`Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"`
);
}
for (const list of [policy.allow, policy.deny]) {
if (!list) continue;
for (const category of list) {
if (!KNOWN_CATEGORIES.has(category)) {
console.warn(
`[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` +
`Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}`
);
}
}
}
return policy;
}
let cachedPolicyPath: string | null = null;
let cachedPolicyMtimeMs = 0;
let cachedPolicy: ActionPolicy | null = null;
const RELOAD_CHECK_INTERVAL_MS = 5_000;
let lastCheckMs = 0;
export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void {
cachedPolicyPath = resolve(policyPath);
cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs;
cachedPolicy = policy;
}
export function reloadPolicyIfChanged(): ActionPolicy | null {
if (!cachedPolicyPath) return cachedPolicy;
const now = Date.now();
if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy;
lastCheckMs = now;
try {
const currentMtime = statSync(cachedPolicyPath).mtimeMs;
if (currentMtime !== cachedPolicyMtimeMs) {
cachedPolicy = loadPolicyFile(cachedPolicyPath);
cachedPolicyMtimeMs = currentMtime;
}
} catch {
// File may have been removed; keep using cached policy
}
return cachedPolicy;
}
export function checkPolicy(
action: string,
policy: ActionPolicy | null,
confirmCategories: Set<string>
): PolicyDecision {
const category = getActionCategory(action);
// Internal actions are always allowed
if (category === '_internal') return 'allow';
// Explicit deny takes precedence over confirmation
if (policy?.deny?.includes(category)) return 'deny';
// Check if this category requires confirmation
if (confirmCategories.has(category)) return 'confirm';
if (!policy) return 'allow';
// Explicit allow list
if (policy.allow?.includes(category)) return 'allow';
return policy.default;
}
export function describeAction(action: string, command: Record<string, unknown>): string {
const category = getActionCategory(action);
switch (action) {
case 'navigate':
return `Navigate to ${command.url}`;
case 'evaluate':
case 'evalhandle':
return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`;
case 'fill':
return `Fill ${command.selector}`;
case 'type':
return `Type into ${command.selector}`;
case 'click':
return `Click ${command.selector}`;
case 'dblclick':
return `Double-click ${command.selector}`;
case 'tap':
return `Tap ${command.selector}`;
case 'download':
return `Download via ${command.selector} to ${command.path}`;
case 'upload':
return `Upload files to ${command.selector}`;
default:
return `${category}: ${action}`;
}
}
-39
View File
@@ -1,39 +0,0 @@
import { describe, it, expect } from 'vitest';
import { toAIFriendlyError } from './actions.js';
describe('toAIFriendlyError', () => {
describe('element blocked by overlay', () => {
it('should detect intercepts pointer events even when Timeout is in message', () => {
// This is the exact error from Playwright when a cookie banner blocks an element
// Bug: Previously this was incorrectly reported as "not found or not visible"
const error = new Error(
'TimeoutError: locator.click: Timeout 10000ms exceeded.\n' +
'Call log:\n' +
" - waiting for getByRole('link', { name: 'Anmelden', exact: true }).first()\n" +
' - locator resolved to <a href="https://example.com/login">Anmelden</a>\n' +
' - attempting click action\n' +
' 2 x waiting for element to be visible, enabled and stable\n' +
' - element is visible, enabled and stable\n' +
' - scrolling into view if needed\n' +
' - done scrolling\n' +
' - <body class="font-sans antialiased">...</body> intercepts pointer events\n' +
' - retrying click action'
);
const result = toAIFriendlyError(error, '@e4');
// Must NOT say "not found" - the element WAS found
expect(result.message).not.toContain('not found');
// Must indicate the element is blocked
expect(result.message).toContain('blocked by another element');
expect(result.message).toContain('modal or overlay');
});
it('should suggest dismissing cookie banners', () => {
const error = new Error('<div class="cookie-overlay"> intercepts pointer events');
const result = toAIFriendlyError(error, '@e1');
expect(result.message).toContain('cookie banners');
});
});
});
-2930
View File
File diff suppressed because it is too large Load Diff
-120
View File
@@ -1,120 +0,0 @@
/**
* Standalone CLI entry point for auth vault operations that don't need a browser.
* Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel.
*
* Usage: node auth-cli.js <json-command>
* Prints a JSON response to stdout and exits.
*/
import {
saveAuthProfile,
getAuthProfileMeta,
listAuthProfiles,
deleteAuthProfile,
} from './auth-vault.js';
interface AuthCommand {
id: string;
action: string;
name?: string;
url?: string;
username?: string;
password?: string;
usernameSelector?: string;
passwordSelector?: string;
submitSelector?: string;
}
function success(id: string, data: Record<string, unknown>): string {
return JSON.stringify({ success: true, id, data });
}
function error(id: string, message: string): string {
return JSON.stringify({ success: false, id, error: message });
}
function run(): void {
const input = process.argv[2];
if (!input) {
process.stderr.write('Usage: node auth-cli.js <json-command>\n');
process.exit(1);
}
let cmd: AuthCommand;
try {
cmd = JSON.parse(input);
} catch {
console.log(error('', 'Invalid JSON input'));
process.exit(1);
return;
}
const id = cmd.id || '';
try {
switch (cmd.action) {
case 'auth_save': {
if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) {
console.log(error(id, 'Missing required fields: name, url, username, password'));
return;
}
const meta = saveAuthProfile({
name: cmd.name,
url: cmd.url,
username: cmd.username,
password: cmd.password,
usernameSelector: cmd.usernameSelector,
passwordSelector: cmd.passwordSelector,
submitSelector: cmd.submitSelector,
});
console.log(
success(id, {
saved: !meta.updated,
updated: meta.updated,
name: meta.name,
url: meta.url,
username: meta.username,
})
);
return;
}
case 'auth_list': {
const profiles = listAuthProfiles();
console.log(success(id, { profiles }));
return;
}
case 'auth_show': {
if (!cmd.name) {
console.log(error(id, 'Missing required field: name'));
return;
}
const meta = getAuthProfileMeta(cmd.name);
if (!meta) {
console.log(error(id, `Auth profile '${cmd.name}' not found`));
return;
}
console.log(success(id, { profile: meta }));
return;
}
case 'auth_delete': {
if (!cmd.name) {
console.log(error(id, 'Missing required field: name'));
return;
}
const deleted = deleteAuthProfile(cmd.name);
if (!deleted) {
console.log(error(id, `Auth profile '${cmd.name}' not found`));
return;
}
console.log(success(id, { deleted: true, name: cmd.name }));
return;
}
default:
console.log(error(id, `Unknown auth action: ${cmd.action}`));
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Operation failed';
console.log(error(id, msg));
}
}
run();
-278
View File
@@ -1,278 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
let tempHome: string;
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
return {
...actual,
default: {
...actual,
homedir: () => tempHome,
},
homedir: () => tempHome,
};
});
import {
saveAuthProfile,
getAuthProfile,
getAuthProfileMeta,
listAuthProfiles,
deleteAuthProfile,
updateLastLogin,
} from './auth-vault.js';
describe('auth-vault', () => {
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-'));
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
});
afterEach(() => {
try {
fs.rmSync(tempHome, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
function cleanAuthDir() {
const authDir = path.join(tempHome, '.agent-browser', 'auth');
if (fs.existsSync(authDir)) {
for (const f of fs.readdirSync(authDir)) {
fs.unlinkSync(path.join(authDir, f));
}
}
}
describe('saveAuthProfile', () => {
it('should save a new profile', () => {
const result = saveAuthProfile({
name: 'github',
url: 'https://github.com/login',
username: 'user',
password: 'pass',
});
expect(result.name).toBe('github');
expect(result.url).toBe('https://github.com/login');
expect(result.username).toBe('user');
expect(result.updated).toBe(false);
expect(result.createdAt).toBeTruthy();
});
it('should mark as updated when overwriting', () => {
saveAuthProfile({
name: 'github',
url: 'https://github.com/login',
username: 'user1',
password: 'pass1',
});
const result = saveAuthProfile({
name: 'github',
url: 'https://github.com/login',
username: 'user2',
password: 'pass2',
});
expect(result.updated).toBe(true);
expect(result.username).toBe('user2');
});
it('should preserve createdAt on update', () => {
const first = saveAuthProfile({
name: 'github',
url: 'https://github.com/login',
username: 'user',
password: 'pass',
});
const second = saveAuthProfile({
name: 'github',
url: 'https://github.com/login',
username: 'user2',
password: 'pass2',
});
expect(second.createdAt).toBe(first.createdAt);
});
it('should save with custom selectors', () => {
saveAuthProfile({
name: 'myapp',
url: 'https://example.com/login',
username: 'user',
password: 'pass',
usernameSelector: '#email',
passwordSelector: '#password',
submitSelector: 'button.login',
});
const profile = getAuthProfile('myapp');
expect(profile).not.toBeNull();
expect(profile!.usernameSelector).toBe('#email');
expect(profile!.passwordSelector).toBe('#password');
expect(profile!.submitSelector).toBe('button.login');
});
it('should reject invalid profile names', () => {
expect(() =>
saveAuthProfile({
name: '../escape',
url: 'https://example.com',
username: 'user',
password: 'pass',
})
).toThrow('only alphanumeric');
});
});
describe('getAuthProfile', () => {
it('should return null for non-existent profile', () => {
expect(getAuthProfile('nonexistent')).toBeNull();
});
it('should return full profile with password', () => {
saveAuthProfile({
name: 'test',
url: 'https://example.com',
username: 'user',
password: 'secret',
});
const profile = getAuthProfile('test');
expect(profile).not.toBeNull();
expect(profile!.password).toBe('secret');
});
});
describe('getAuthProfileMeta', () => {
it('should return metadata without password', () => {
saveAuthProfile({
name: 'test',
url: 'https://example.com',
username: 'user',
password: 'secret',
});
const meta = getAuthProfileMeta('test');
expect(meta).not.toBeNull();
expect(meta!.name).toBe('test');
expect(meta!.username).toBe('user');
expect((meta as Record<string, unknown>).password).toBeUndefined();
});
it('should return null for non-existent profile', () => {
expect(getAuthProfileMeta('nonexistent')).toBeNull();
});
});
describe('listAuthProfiles', () => {
it('should return empty array when no profiles', () => {
cleanAuthDir();
expect(listAuthProfiles()).toEqual([]);
});
it('should list all saved profiles', () => {
cleanAuthDir();
saveAuthProfile({
name: 'github',
url: 'https://github.com/login',
username: 'user1',
password: 'pass1',
});
saveAuthProfile({
name: 'gitlab',
url: 'https://gitlab.com/login',
username: 'user2',
password: 'pass2',
});
const profiles = listAuthProfiles();
expect(profiles).toHaveLength(2);
const names = profiles.map((p) => p.name).sort();
expect(names).toEqual(['github', 'gitlab']);
});
});
describe('deleteAuthProfile', () => {
it('should delete an existing profile', () => {
saveAuthProfile({
name: 'test',
url: 'https://example.com',
username: 'user',
password: 'pass',
});
expect(deleteAuthProfile('test')).toBe(true);
expect(getAuthProfile('test')).toBeNull();
});
it('should return false for non-existent profile', () => {
expect(deleteAuthProfile('nonexistent')).toBe(false);
});
});
describe('updateLastLogin', () => {
it('should update lastLoginAt timestamp', () => {
saveAuthProfile({
name: 'test',
url: 'https://example.com',
username: 'user',
password: 'pass',
});
const metaBefore = getAuthProfileMeta('test');
expect(metaBefore!.lastLoginAt).toBeUndefined();
updateLastLogin('test');
const metaAfter = getAuthProfileMeta('test');
expect(metaAfter!.lastLoginAt).toBeTruthy();
});
});
describe('auto-generated encryption key', () => {
it('should auto-create key file and encrypt profile when no env var is set', () => {
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
saveAuthProfile({
name: 'autokey',
url: 'https://example.com',
username: 'user',
password: 'secret',
});
const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key');
expect(fs.existsSync(keyFilePath)).toBe(true);
const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim();
expect(keyHex).toMatch(/^[a-f0-9]{64}$/);
const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json');
const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
expect(raw.encrypted).toBe(true);
expect(raw.iv).toBeTruthy();
});
it('should read back profile using auto-generated key', () => {
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
saveAuthProfile({
name: 'readback',
url: 'https://example.com',
username: 'user',
password: 'secret123',
});
const profile = getAuthProfile('readback');
expect(profile).not.toBeNull();
expect(profile!.password).toBe('secret123');
});
});
});
-189
View File
@@ -1,189 +0,0 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
unlinkSync,
} from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
getEncryptionKey,
ensureEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
getKeyFilePath,
restrictFilePermissions,
restrictDirPermissions,
type EncryptedPayload,
} from './encryption.js';
const AUTH_DIR = 'auth';
interface AuthProfile {
name: string;
url: string;
username: string;
password: string;
usernameSelector?: string;
passwordSelector?: string;
submitSelector?: string;
createdAt: string;
lastLoginAt?: string;
}
export interface AuthProfileMeta {
name: string;
url: string;
username: string;
createdAt: string;
lastLoginAt?: string;
}
function getAuthDir(): string {
const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
restrictDirPermissions(dir);
}
return dir;
}
const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
function validateProfileName(name: string): void {
if (!SAFE_NAME_RE.test(name)) {
throw new Error(
`Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed`
);
}
}
function profilePath(name: string): string {
validateProfileName(name);
return path.join(getAuthDir(), `${name}.json`);
}
function readProfile(name: string): AuthProfile | null {
const p = profilePath(name);
if (!existsSync(p)) return null;
const raw = readFileSync(p, 'utf-8');
const parsed = JSON.parse(raw);
if (isEncryptedPayload(parsed)) {
const key = getEncryptionKey();
if (!key) {
throw new Error(
`Encryption key required to read encrypted auth profiles. ` +
`Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.`
);
}
const decrypted = decryptData(parsed as EncryptedPayload, key);
return JSON.parse(decrypted) as AuthProfile;
}
return parsed as AuthProfile;
}
function writeProfile(profile: AuthProfile): void {
const key = ensureEncryptionKey();
const serialized = JSON.stringify(profile, null, 2);
const encrypted = encryptData(serialized, key);
const filePath = profilePath(profile.name);
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), {
mode: 0o600,
});
restrictFilePermissions(filePath);
}
export function saveAuthProfile(opts: {
name: string;
url: string;
username: string;
password: string;
usernameSelector?: string;
passwordSelector?: string;
submitSelector?: string;
}): AuthProfileMeta & { updated: boolean } {
const existing = readProfile(opts.name);
const profile: AuthProfile = {
name: opts.name,
url: opts.url,
username: opts.username,
password: opts.password,
usernameSelector: opts.usernameSelector,
passwordSelector: opts.passwordSelector,
submitSelector: opts.submitSelector,
createdAt: existing?.createdAt ?? new Date().toISOString(),
lastLoginAt: existing?.lastLoginAt,
};
writeProfile(profile);
return {
name: profile.name,
url: profile.url,
username: profile.username,
createdAt: profile.createdAt,
lastLoginAt: profile.lastLoginAt,
updated: existing !== null,
};
}
export function getAuthProfile(name: string): AuthProfile | null {
return readProfile(name);
}
export function getAuthProfileMeta(name: string): AuthProfileMeta | null {
const profile = readProfile(name);
if (!profile) return null;
return {
name: profile.name,
url: profile.url,
username: profile.username,
createdAt: profile.createdAt,
lastLoginAt: profile.lastLoginAt,
};
}
export function listAuthProfiles(): AuthProfileMeta[] {
const dir = getAuthDir();
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
const profiles: AuthProfileMeta[] = [];
for (const file of files) {
const name = file.replace(/\.json$/, '');
try {
const meta = getAuthProfileMeta(name);
if (meta) profiles.push(meta);
} catch {
profiles.push({
name,
url: '(encrypted)',
username: '(encrypted)',
createdAt: '(unknown)',
});
}
}
return profiles;
}
export function deleteAuthProfile(name: string): boolean {
const p = profilePath(name);
if (!existsSync(p)) return false;
unlinkSync(p);
return true;
}
export function updateLastLogin(name: string): void {
const profile = readProfile(name);
if (profile) {
profile.lastLoginAt = new Date().toISOString();
writeProfile(profile);
}
}
-1414
View File
File diff suppressed because it is too large Load Diff
-2834
View File
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { requestConfirmation, getAndRemovePending } from './confirmation.js';
describe('confirmation', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('requestConfirmation', () => {
it('should return a confirmation ID', () => {
const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' });
expect(result.confirmationId).toBeTruthy();
expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/);
});
it('should generate unique IDs', () => {
const r1 = requestConfirmation('evaluate', 'eval', 'desc', {});
const r2 = requestConfirmation('click', 'click', 'desc', {});
expect(r1.confirmationId).not.toBe(r2.confirmationId);
});
});
describe('getAndRemovePending', () => {
it('should retrieve and remove a pending confirmation', () => {
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {
action: 'evaluate',
script: 'test',
});
const entry = getAndRemovePending(confirmationId);
expect(entry).not.toBeNull();
expect(entry!.action).toBe('evaluate');
expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' });
});
it('should return null on second retrieval (already removed)', () => {
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
getAndRemovePending(confirmationId);
expect(getAndRemovePending(confirmationId)).toBeNull();
});
it('should return null for non-existent ID', () => {
expect(getAndRemovePending('c_nonexistent')).toBeNull();
});
it('should auto-deny after 60 seconds', () => {
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
vi.advanceTimersByTime(60_000);
expect(getAndRemovePending(confirmationId)).toBeNull();
});
it('should still be retrievable before 60 second timeout', () => {
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
vi.advanceTimersByTime(59_999);
const entry = getAndRemovePending(confirmationId);
expect(entry).not.toBeNull();
});
});
});
-53
View File
@@ -1,53 +0,0 @@
import { randomBytes } from 'node:crypto';
interface PendingConfirmation {
id: string;
action: string;
category: string;
description: string;
command: Record<string, unknown>;
timer: ReturnType<typeof setTimeout>;
}
const AUTO_DENY_TIMEOUT_MS = 60_000;
const pending = new Map<string, PendingConfirmation>();
function generateId(): string {
return `c_${randomBytes(8).toString('hex')}`;
}
export function requestConfirmation(
action: string,
category: string,
description: string,
command: Record<string, unknown>
): { confirmationId: string } {
const id = generateId();
const timer = setTimeout(() => {
pending.delete(id);
}, AUTO_DENY_TIMEOUT_MS);
pending.set(id, {
id,
action,
category,
description,
command,
timer,
});
return { confirmationId: id };
}
export function getAndRemovePending(
id: string
): { command: Record<string, unknown>; action: string } | null {
const entry = pending.get(id);
if (!entry) return null;
clearTimeout(entry.timer);
pending.delete(id);
return { command: entry.command, action: entry.action };
}
-184
View File
@@ -1,184 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as os from 'os';
import * as path from 'path';
import * as net from 'net';
import { EventEmitter } from 'events';
import { getSocketDir, safeWrite, getPortForSession } from './daemon.js';
/**
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
* This pattern detects HTTP method prefixes that browsers must send when using fetch().
*/
const HTTP_REQUEST_PATTERN = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i;
describe('HTTP request detection (security)', () => {
it('should detect POST requests from fetch()', () => {
const httpRequest = 'POST / HTTP/1.1\r\nHost: 127.0.0.1:51234\r\n';
expect(HTTP_REQUEST_PATTERN.test(httpRequest.trimStart())).toBe(true);
});
it('should detect GET requests', () => {
expect(HTTP_REQUEST_PATTERN.test('GET / HTTP/1.1')).toBe(true);
});
it('should detect OPTIONS preflight requests', () => {
expect(HTTP_REQUEST_PATTERN.test('OPTIONS / HTTP/1.1')).toBe(true);
});
it('should NOT detect valid JSON commands', () => {
const jsonCommand = '{"id":"1","action":"navigate","url":"https://example.com"}';
expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
});
it('should NOT detect JSON with leading whitespace', () => {
const jsonCommand = ' {"id":"1","action":"click","selector":"button"}';
expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
});
it('should be case insensitive for HTTP methods', () => {
expect(HTTP_REQUEST_PATTERN.test('post / HTTP/1.1')).toBe(true);
expect(HTTP_REQUEST_PATTERN.test('Post / HTTP/1.1')).toBe(true);
});
});
describe('getSocketDir', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear relevant env vars before each test
delete process.env.AGENT_BROWSER_SOCKET_DIR;
delete process.env.XDG_RUNTIME_DIR;
});
afterEach(() => {
// Restore original env
process.env = { ...originalEnv };
});
describe('AGENT_BROWSER_SOCKET_DIR', () => {
it('should use custom path when set', () => {
process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/socket/path';
expect(getSocketDir()).toBe('/custom/socket/path');
});
it('should ignore empty string', () => {
process.env.AGENT_BROWSER_SOCKET_DIR = '';
const result = getSocketDir();
expect(result).toContain('.agent-browser');
});
it('should take priority over XDG_RUNTIME_DIR', () => {
process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/path';
process.env.XDG_RUNTIME_DIR = '/run/user/1000';
expect(getSocketDir()).toBe('/custom/path');
});
});
describe('XDG_RUNTIME_DIR', () => {
it('should use when AGENT_BROWSER_SOCKET_DIR is not set', () => {
process.env.XDG_RUNTIME_DIR = '/run/user/1000';
expect(getSocketDir()).toBe('/run/user/1000/agent-browser');
});
it('should ignore empty string', () => {
process.env.AGENT_BROWSER_SOCKET_DIR = '';
process.env.XDG_RUNTIME_DIR = '';
const result = getSocketDir();
expect(result).toContain('.agent-browser');
});
});
describe('fallback', () => {
it('should use home directory when env vars are not set', () => {
const result = getSocketDir();
const expected = path.join(os.homedir(), '.agent-browser');
expect(result).toBe(expected);
});
});
});
function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
const emitter = new EventEmitter();
const socket = Object.assign(emitter, {
destroyed: opts.destroyed ?? false,
write: vi.fn().mockReturnValue(opts.writeReturns ?? true),
removeListener: emitter.removeListener.bind(emitter),
});
return socket as unknown as net.Socket;
}
describe('safeWrite', () => {
it('should resolve immediately when socket.write returns true', async () => {
const socket = createMockSocket({ writeReturns: true });
await safeWrite(socket, 'hello\n');
expect(socket.write).toHaveBeenCalledWith('hello\n');
});
it('should resolve immediately when socket is already destroyed', async () => {
const socket = createMockSocket({ destroyed: true });
await safeWrite(socket, 'hello\n');
expect(socket.write).not.toHaveBeenCalled();
});
it('should wait for drain event when socket.write returns false', async () => {
const socket = createMockSocket({ writeReturns: false });
const promise = safeWrite(socket, 'big payload');
// Simulate drain after a tick
setTimeout(() => socket.emit('drain'), 0);
await promise;
expect(socket.write).toHaveBeenCalledWith('big payload');
});
it('should reject on socket error while waiting for drain', async () => {
const socket = createMockSocket({ writeReturns: false });
const promise = safeWrite(socket, 'data');
setTimeout(() => socket.emit('error', new Error('connection reset')), 0);
await expect(promise).rejects.toThrow('connection reset');
});
it('should resolve on socket close while waiting for drain', async () => {
const socket = createMockSocket({ writeReturns: false });
const promise = safeWrite(socket, 'data');
setTimeout(() => socket.emit('close'), 0);
await promise;
});
it('should clean up listeners after drain resolves', async () => {
const socket = createMockSocket({ writeReturns: false });
const promise = safeWrite(socket, 'data');
setTimeout(() => socket.emit('drain'), 0);
await promise;
expect(socket.listenerCount('drain')).toBe(0);
expect(socket.listenerCount('error')).toBe(0);
expect(socket.listenerCount('close')).toBe(0);
});
});
describe('getPortForSession', () => {
it('returns consistent port for "default"', () => {
expect(getPortForSession('default')).toBe(50838);
});
it('returns consistent port for named sessions', () => {
expect(getPortForSession('my-session')).toBe(63105);
expect(getPortForSession('work')).toBe(51184);
});
it('returns base port for empty session', () => {
expect(getPortForSession('')).toBe(49152);
});
it('returns port within dynamic range (49152-65535)', () => {
for (const name of ['default', 'my-session', 'work', 'test', 'a']) {
const port = getPortForSession(name);
expect(port).toBeGreaterThanOrEqual(49152);
expect(port).toBeLessThanOrEqual(65535);
}
});
});
-772
View File
@@ -1,772 +0,0 @@
import * as net from 'net';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { BrowserManager } from './browser.js';
import { IOSManager } from './ios-manager.js';
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand, initActionPolicy } from './actions.js';
import { executeIOSCommand } from './ios-actions.js';
import { StreamServer } from './stream-server.js';
import {
getSessionsDir,
ensureSessionsDir,
getEncryptionKey,
encryptData,
isValidSessionName,
cleanupExpiredStates,
getAutoStateFilePath,
} from './state-utils.js';
// Manager type - either desktop browser or iOS
type Manager = BrowserManager | IOSManager;
/**
* Backpressure-aware socket write.
* If the kernel buffer is full (socket.write returns false),
* waits for the 'drain' event before resolving.
*/
export function safeWrite(socket: net.Socket, payload: string): Promise<void> {
return new Promise((resolve, reject) => {
if (socket.destroyed) {
resolve();
return;
}
const canContinue = socket.write(payload);
if (canContinue) {
resolve();
} else if (socket.destroyed) {
resolve();
} else {
const cleanup = () => {
socket.removeListener('drain', onDrain);
socket.removeListener('error', onError);
socket.removeListener('close', onClose);
};
const onDrain = () => {
cleanup();
resolve();
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const onClose = () => {
cleanup();
resolve();
};
socket.once('drain', onDrain);
socket.once('error', onError);
socket.once('close', onClose);
}
});
}
// Platform detection
const isWindows = process.platform === 'win32';
// Session support - each session gets its own socket/pid
let currentSession = process.env.AGENT_BROWSER_SESSION || 'default';
// Stream server for browser preview
let streamServer: StreamServer | null = null;
// Idle timeout - shut down daemon after period of inactivity
// Configurable via AGENT_BROWSER_IDLE_TIMEOUT_MS env var (default: 15 minutes, 0 to disable)
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
const IDLE_TIMEOUT_MS = (() => {
const env = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS;
if (env !== undefined) {
const val = parseInt(env, 10);
return isNaN(val) ? DEFAULT_IDLE_TIMEOUT_MS : val;
}
return DEFAULT_IDLE_TIMEOUT_MS;
})();
let idleTimer: ReturnType<typeof setTimeout> | null = null;
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
const DEFAULT_STREAM_PORT = 9223;
/**
* Save state to file with optional encryption.
*/
async function saveStateToFile(
browser: BrowserManager,
filepath: string
): Promise<{ encrypted: boolean }> {
const context = browser.getContext();
if (!context) {
throw new Error('No browser context available');
}
const state = await context.storageState();
const jsonData = JSON.stringify(state, null, 2);
const key = getEncryptionKey();
if (key) {
const encrypted = encryptData(jsonData, key);
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
return { encrypted: true };
}
fs.writeFileSync(filepath, jsonData);
return { encrypted: false };
}
const AUTO_EXPIRE_ENV = 'AGENT_BROWSER_STATE_EXPIRE_DAYS';
const DEFAULT_EXPIRE_DAYS = 30;
function runCleanupExpiredStates(): void {
const expireDaysStr = process.env[AUTO_EXPIRE_ENV];
const expireDays = expireDaysStr ? parseInt(expireDaysStr, 10) : DEFAULT_EXPIRE_DAYS;
if (isNaN(expireDays) || expireDays <= 0) {
return;
}
try {
const deleted = cleanupExpiredStates(expireDays);
if (deleted.length > 0 && process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Auto-expired ${deleted.length} state file(s) older than ${expireDays} days`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed to clean up expired states:`, err);
}
}
}
/**
* Get the validated session name and auto-state file path.
* Centralizes session name validation to prevent path traversal.
*/
function getSessionAutoStatePath(): string | undefined {
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
if (!sessionNameRaw) return undefined;
if (!isValidSessionName(sessionNameRaw)) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[SECURITY] Invalid session name rejected: ${sessionNameRaw}`);
}
return undefined;
}
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
try {
const autoStatePath = getAutoStateFilePath(sessionNameRaw, sessionId);
return autoStatePath && fs.existsSync(autoStatePath) ? autoStatePath : undefined;
} catch {
return undefined;
}
}
/**
* Get the auto-state file path for saving (creates sessions dir if needed).
* Returns undefined if no valid session name is configured.
*/
function getSessionSaveStatePath(): string | undefined {
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
if (!sessionNameRaw) return undefined;
if (!isValidSessionName(sessionNameRaw)) return undefined;
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
try {
return getAutoStateFilePath(sessionNameRaw, sessionId) ?? undefined;
} catch {
return undefined;
}
}
/**
* Set the current session
*/
export function setSession(session: string): void {
currentSession = session;
}
/**
* Get the current session
*/
export function getSession(): string {
return currentSession;
}
/**
* Get port number for TCP mode (Windows)
* Uses a hash of the session name to get a consistent port
*/
export function getPortForSession(session: string): number {
let hash = 0;
for (let i = 0; i < session.length; i++) {
hash = (hash << 5) - hash + session.charCodeAt(i);
hash |= 0;
}
// Port range 49152-65535 (dynamic/private ports)
return 49152 + (Math.abs(hash) % 16383);
}
/**
* Get the base directory for socket/pid files.
* Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
*/
export function getAppDir(): string {
// 1. XDG_RUNTIME_DIR (Linux standard)
if (process.env.XDG_RUNTIME_DIR) {
return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
}
// 2. Home directory fallback (like Docker Desktop's ~/.docker/run/)
const homeDir = os.homedir();
if (homeDir) {
return path.join(homeDir, '.agent-browser');
}
// 3. Last resort: temp dir
return path.join(os.tmpdir(), 'agent-browser');
}
export function getSocketDir(): string {
// Allow explicit override for socket directory
if (process.env.AGENT_BROWSER_SOCKET_DIR) {
return process.env.AGENT_BROWSER_SOCKET_DIR;
}
return getAppDir();
}
/**
* Get the socket path for the current session (Unix) or port (Windows)
*/
export function getSocketPath(session?: string): string {
const sess = session ?? currentSession;
if (isWindows) {
return String(getPortForSession(sess));
}
return path.join(getSocketDir(), `${sess}.sock`);
}
/**
* Get the port file path for Windows (stores the port number)
*/
export function getPortFile(session?: string): string {
const sess = session ?? currentSession;
return path.join(getSocketDir(), `${sess}.port`);
}
/**
* Get the PID file path for the current session
*/
export function getPidFile(session?: string): string {
const sess = session ?? currentSession;
return path.join(getSocketDir(), `${sess}.pid`);
}
/**
* Check if daemon is running for the current session
*/
export function isDaemonRunning(session?: string): boolean {
const pidFile = getPidFile(session);
if (!fs.existsSync(pidFile)) return false;
try {
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
// Check if process exists (works on both Unix and Windows)
process.kill(pid, 0);
return true;
} catch (err: unknown) {
// EPERM means the process exists but we lack permission to signal it
// (e.g. caller is inside a macOS sandbox). Only ESRCH means it's gone.
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EPERM') {
return true;
}
// Process doesn't exist, clean up stale files
cleanupSocket(session);
return false;
}
}
/**
* Get connection info for the current session
* Returns { type: 'unix', path: string } or { type: 'tcp', port: number }
*/
export function getConnectionInfo(
session?: string
): { type: 'unix'; path: string } | { type: 'tcp'; port: number } {
const sess = session ?? currentSession;
if (isWindows) {
return { type: 'tcp', port: getPortForSession(sess) };
}
return { type: 'unix', path: path.join(getSocketDir(), `${sess}.sock`) };
}
/**
* Clean up socket and PID file for the current session
*/
export function cleanupSocket(session?: string): void {
const pidFile = getPidFile(session);
const streamPortFile = getStreamPortFile(session);
try {
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
if (isWindows) {
const portFile = getPortFile(session);
if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
} else {
const socketPath = getSocketPath(session);
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
}
} catch {
// Ignore cleanup errors
}
}
/**
* Get the stream port file path
*/
export function getStreamPortFile(session?: string): string {
const sess = session ?? currentSession;
return path.join(getSocketDir(), `${sess}.stream`);
}
/**
* Start the daemon server
* @param options.streamPort Port for WebSocket stream server (0 to disable)
* @param options.provider Provider type ('ios' for iOS Simulator, undefined for desktop)
*/
export async function startDaemon(options?: {
streamPort?: number;
provider?: string;
}): Promise<void> {
// Ensure socket directory exists with restricted permissions (owner-only access)
const socketDir = getSocketDir();
if (!fs.existsSync(socketDir)) {
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
}
// Clean up any stale socket
cleanupSocket();
// Clean up expired state files on startup
runCleanupExpiredStates();
// Initialize action policy enforcement
initActionPolicy();
// Determine provider from options or environment
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
const isIOS = provider === 'ios';
// Create appropriate manager
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
let shuttingDown = false;
// Start stream server if port is specified (or use default if env var is set)
// Note: Stream server only works with BrowserManager (desktop), not iOS
const streamPort =
options?.streamPort ??
(process.env.AGENT_BROWSER_STREAM_PORT
? parseInt(process.env.AGENT_BROWSER_STREAM_PORT, 10)
: 0);
if (streamPort > 0 && !isIOS && manager instanceof BrowserManager) {
streamServer = new StreamServer(manager, streamPort);
await streamServer.start();
// Write stream port to file for clients to discover
const streamPortFile = getStreamPortFile();
fs.writeFileSync(streamPortFile, streamPort.toString());
}
// Idle timeout: shut down daemon if no commands arrive within the timeout period.
// Reset on every incoming command. Set AGENT_BROWSER_IDLE_TIMEOUT_MS=0 to disable.
let shutdownRef: (() => Promise<void>) | null = null;
function resetIdleTimer(): void {
if (IDLE_TIMEOUT_MS <= 0) return;
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Idle timeout reached (${IDLE_TIMEOUT_MS}ms), shutting down daemon`);
}
if (shutdownRef) shutdownRef();
}, IDLE_TIMEOUT_MS);
// Don't let the idle timer keep the process alive on its own
if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
idleTimer.unref();
}
}
// Start the idle timer immediately
resetIdleTimer();
const server = net.createServer((socket) => {
let buffer = '';
let httpChecked = false;
// Command serialization: queue incoming lines and process them one at a time.
// This prevents concurrent command execution which can cause socket.write
// buffer contention and EAGAIN errors on the Rust CLI side.
const commandQueue: string[] = [];
let processing = false;
async function processQueue(): Promise<void> {
if (processing) return;
processing = true;
while (commandQueue.length > 0) {
const line = commandQueue.shift()!;
// Reset idle timer on every command
resetIdleTimer();
try {
const parseResult = parseCommand(line);
if (!parseResult.success) {
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
await safeWrite(socket, serializeResponse(resp) + '\n');
continue;
}
// Handle device_list specially - it works without a session and always uses IOSManager
if (parseResult.command.action === 'device_list') {
const iosManager = new IOSManager();
try {
const devices = await iosManager.listAllDevices();
const response = {
id: parseResult.command.id,
success: true as const,
data: { devices },
};
await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await safeWrite(
socket,
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
);
}
continue;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' &&
parseResult.command.action !== 'state_load'
) {
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
// Check for device in command first (for reused daemons), then fall back to env vars
const cmd = parseResult.command as { iosDevice?: string };
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
await manager.launch({
device: iosDevice,
udid: process.env.AGENT_BROWSER_IOS_UDID,
});
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/)
.map((p) => p.trim())
.filter(Boolean)
: undefined;
// Parse args from env (comma or newline separated)
const argsEnv = process.env.AGENT_BROWSER_ARGS;
const args = argsEnv
? argsEnv
.split(/[,\n]/)
.map((a) => a.trim())
.filter((a) => a.length > 0)
: undefined;
// Parse proxy from env
const proxyServer = process.env.AGENT_BROWSER_PROXY;
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
const proxy = proxyServer
? {
server: proxyServer,
...(proxyBypass && { bypass: proxyBypass }),
}
: undefined;
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
const colorScheme =
colorSchemeEnv === 'dark' ||
colorSchemeEnv === 'light' ||
colorSchemeEnv === 'no-preference'
? colorSchemeEnv
: undefined;
await manager.launch({
headless:
process.env.AGENT_BROWSER_HEADED !== '1' &&
process.env.AGENT_BROWSER_HEADED !== 'true',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
allowFileAccess: allowFileAccess,
colorScheme,
autoStateFilePath: getSessionAutoStatePath(),
});
}
}
// Recover from stale state: browser is launched but all pages were closed
if (
manager instanceof BrowserManager &&
manager.isLaunched() &&
!manager.hasPages() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close'
) {
await manager.ensurePage();
}
// Handle explicit launch with auto-load state
if (
parseResult.command.action === 'launch' &&
manager instanceof BrowserManager &&
!parseResult.command.autoStateFilePath
) {
const autoStatePath = getSessionAutoStatePath();
if (autoStatePath) {
parseResult.command.autoStateFilePath = autoStatePath;
}
}
// Handle close command specially - shuts down daemon
if (parseResult.command.action === 'close') {
// Auto-save state before closing
if (manager instanceof BrowserManager && manager.isLaunched()) {
const savePath = getSessionSaveStatePath();
if (savePath) {
try {
const { encrypted } = await saveStateToFile(manager, savePath);
fs.chmodSync(savePath, 0o600);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`Failed to auto-save session state:`, err);
}
}
}
}
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
await safeWrite(socket, serializeResponse(response) + '\n');
if (!shuttingDown) {
shuttingDown = true;
setTimeout(() => {
server.close();
cleanupSocket();
process.exit(0);
}, 100);
}
commandQueue.length = 0;
processing = false;
return;
}
// Execute command with appropriate handler
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
// Add any launch warnings to the response
if (manager instanceof BrowserManager) {
const warnings = manager.getAndClearWarnings();
if (warnings.length > 0 && response.success && response.data) {
(response.data as Record<string, unknown>).warnings = warnings;
}
}
await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await safeWrite(socket, serializeResponse(errorResponse('error', message)) + '\n').catch(
() => {}
); // Socket may already be destroyed
}
}
processing = false;
}
socket.on('data', (data) => {
buffer += data.toString();
// Security: Detect and reject HTTP requests to prevent cross-origin attacks.
// Browsers using fetch() must send HTTP headers (e.g., "POST / HTTP/1.1"),
// while legitimate clients send raw JSON starting with "{".
if (!httpChecked) {
httpChecked = true;
const trimmed = buffer.trimStart();
if (/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i.test(trimmed)) {
socket.destroy();
return;
}
}
// Extract complete lines and enqueue them for serial processing
while (buffer.includes('\n')) {
const newlineIdx = buffer.indexOf('\n');
const line = buffer.substring(0, newlineIdx);
buffer = buffer.substring(newlineIdx + 1);
if (!line.trim()) continue;
commandQueue.push(line);
}
processQueue().catch((err) => {
// Socket write failures during queue processing are non-fatal;
// the client has likely disconnected.
// Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects.
console.warn('[warn] processQueue error:', err?.message ?? String(err));
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
'[DEBUG] processQueue error stack:',
err?.stack ?? err?.message ?? String(err)
);
}
});
});
socket.on('error', () => {
// Client disconnected, ignore
});
});
const pidFile = getPidFile();
// Write PID file before listening
fs.writeFileSync(pidFile, process.pid.toString());
if (isWindows) {
// Windows: use TCP socket on localhost
const port = getPortForSession(currentSession);
const portFile = getPortFile();
fs.writeFileSync(portFile, port.toString());
server.listen(port, '127.0.0.1', () => {
// Daemon is ready on TCP port
});
} else {
// Unix: use Unix domain socket
const socketPath = getSocketPath();
server.listen(socketPath, () => {
// Daemon is ready
});
}
server.on('error', (err) => {
console.error('Server error:', err);
cleanupSocket();
process.exit(1);
});
// Handle shutdown signals
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
// Clear idle timer
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
// Auto-save session state before closing (same as the explicit `close` command path)
if (manager instanceof BrowserManager && manager.isLaunched()) {
const savePath = getSessionSaveStatePath();
if (savePath) {
try {
const { encrypted } = await saveStateToFile(manager, savePath);
fs.chmodSync(savePath, 0o600);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`Auto-saved session state before shutdown: ${savePath}${encrypted ? ' (encrypted)' : ''}`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`Failed to auto-save session state before shutdown:`, err);
}
}
}
}
// Stop stream server if running
if (streamServer) {
await streamServer.stop();
streamServer = null;
// Clean up stream port file
const streamPortFile = getStreamPortFile();
try {
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
} catch {
// Ignore cleanup errors
}
}
await manager.close();
server.close();
cleanupSocket();
process.exit(0);
};
// Wire up idle timeout to shutdown
shutdownRef = shutdown;
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGHUP', shutdown);
// Handle unexpected errors - always cleanup
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
cleanupSocket();
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
cleanupSocket();
process.exit(1);
});
// Cleanup on normal exit
process.on('exit', () => {
cleanupSocket();
});
// Keep process alive
process.stdin.resume();
}
// Run daemon if this is the entry point
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
startDaemon().catch((err) => {
console.error('Daemon error:', err);
cleanupSocket();
process.exit(1);
});
}
-189
View File
@@ -1,189 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { diffSnapshots, diffScreenshots } from './diff.js';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
describe('diffSnapshots', () => {
it('should report no changes for identical inputs', () => {
const text = 'heading "Hello"\nbutton "Submit" [ref=e1]';
const result = diffSnapshots(text, text);
expect(result.changed).toBe(false);
expect(result.additions).toBe(0);
expect(result.removals).toBe(0);
expect(result.unchanged).toBe(2);
});
it('should report no changes for empty inputs', () => {
const result = diffSnapshots('', '');
expect(result.changed).toBe(false);
expect(result.additions).toBe(0);
expect(result.removals).toBe(0);
expect(result.unchanged).toBe(1);
});
it('should detect a single-line addition', () => {
const before = 'heading "Hello"';
const after = 'heading "Hello"\nbutton "New"';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBe(1);
expect(result.removals).toBe(0);
expect(result.unchanged).toBe(1);
expect(result.diff).toContain('+ button "New"');
});
it('should detect a single-line removal', () => {
const before = 'heading "Hello"\nbutton "Gone"';
const after = 'heading "Hello"';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBe(0);
expect(result.removals).toBe(1);
expect(result.unchanged).toBe(1);
expect(result.diff).toContain('- button "Gone"');
});
it('should detect completely different inputs', () => {
const before = 'line A\nline B';
const after = 'line C\nline D';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBe(2);
expect(result.removals).toBe(2);
expect(result.unchanged).toBe(0);
});
it('should handle mixed additions, removals, and unchanged lines', () => {
const before = [
'heading "Title"',
'button "Submit" [ref=e2]',
'text "old value"',
'footer "Copyright"',
].join('\n');
const after = [
'heading "Title"',
'button "Submit" [ref=e2] [disabled]',
'text "new value"',
'link "Help" [ref=e5]',
'footer "Copyright"',
].join('\n');
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBeGreaterThan(0);
expect(result.removals).toBeGreaterThan(0);
expect(result.unchanged).toBeGreaterThan(0);
expect(result.diff).toContain('+ ');
expect(result.diff).toContain('- ');
});
it('should use + prefix for insertions and - prefix for deletions', () => {
const before = 'alpha';
const after = 'beta';
const result = diffSnapshots(before, after);
const lines = result.diff.split('\n');
const deletions = lines.filter((l) => l.startsWith('- '));
const insertions = lines.filter((l) => l.startsWith('+ '));
expect(deletions.length).toBe(1);
expect(insertions.length).toBe(1);
expect(deletions[0]).toBe('- alpha');
expect(insertions[0]).toBe('+ beta');
});
it('should use two-space prefix for unchanged lines', () => {
const text = 'unchanged line';
const result = diffSnapshots(text, text);
expect(result.diff).toBe(' unchanged line');
});
it('should handle multiline to empty', () => {
const before = 'line 1\nline 2\nline 3';
const after = '';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.removals).toBeGreaterThanOrEqual(3);
});
it('should handle empty to multiline', () => {
const before = '';
const after = 'line 1\nline 2\nline 3';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBeGreaterThanOrEqual(3);
});
});
const canLaunchBrowser = await (async () => {
try {
const b = await chromium.launch({ headless: true });
await b.close();
return true;
} catch {
return false;
}
})();
describe.skipIf(!canLaunchBrowser)('diffScreenshots', () => {
let browser: Browser;
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
context = await browser.newContext({ viewport: { width: 200, height: 200 } });
page = await context.newPage();
});
afterAll(async () => {
await browser.close();
});
async function screenshotOfColor(color: string): Promise<Buffer> {
await page.setContent(`<div style="width:200px;height:200px;background:${color}"></div>`);
return await page.screenshot({ type: 'png' });
}
it('should report match for identical images', async () => {
const img = await screenshotOfColor('red');
const result = await diffScreenshots(context, img, img, {});
expect(result.match).toBe(true);
expect(result.differentPixels).toBe(0);
expect(result.mismatchPercentage).toBe(0);
expect(result.dimensionMismatch).toBeUndefined();
if (result.diffPath) fs.unlinkSync(result.diffPath);
});
it('should detect differences between distinct images', async () => {
const imgA = await screenshotOfColor('red');
const imgB = await screenshotOfColor('blue');
const result = await diffScreenshots(context, imgA, imgB, {});
expect(result.match).toBe(false);
expect(result.differentPixels).toBeGreaterThan(0);
expect(result.mismatchPercentage).toBeGreaterThan(0);
if (result.diffPath) fs.unlinkSync(result.diffPath);
});
it('should detect dimension mismatch', async () => {
const imgA = await screenshotOfColor('white');
await page.setViewportSize({ width: 100, height: 100 });
const imgB = await screenshotOfColor('white');
await page.setViewportSize({ width: 200, height: 200 });
const result = await diffScreenshots(context, imgA, imgB, {});
expect(result.dimensionMismatch).toBe(true);
expect(result.mismatchPercentage).toBe(100);
if (result.diffPath) fs.unlinkSync(result.diffPath);
});
it('should write diff image to custom outputPath', async () => {
const imgA = await screenshotOfColor('green');
const imgB = await screenshotOfColor('yellow');
const outputPath = path.join(os.tmpdir(), `diff-test-${Date.now()}.png`);
const result = await diffScreenshots(context, imgA, imgB, { outputPath });
expect(result.diffPath).toBe(outputPath);
expect(fs.existsSync(outputPath)).toBe(true);
const stat = fs.statSync(outputPath);
expect(stat.size).toBeGreaterThan(0);
fs.unlinkSync(outputPath);
});
});
-339
View File
@@ -1,339 +0,0 @@
import type { BrowserContext } from 'playwright-core';
import type { DiffSnapshotData, DiffScreenshotData } from './types.js';
import { writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
// --- Text diffing (Myers algorithm, line-level) ---
interface DiffEdit {
type: 'equal' | 'insert' | 'delete';
line: string;
}
/**
* Myers diff algorithm operating on arrays of lines.
* Returns a minimal edit script.
*/
function myersDiff(a: string[], b: string[]): DiffEdit[] {
const n = a.length;
const m = b.length;
const max = n + m;
if (max === 0) return [];
// Optimize: if both are identical, skip diff
if (n === m) {
let identical = true;
for (let i = 0; i < n; i++) {
if (a[i] !== b[i]) {
identical = false;
break;
}
}
if (identical) return a.map((line) => ({ type: 'equal' as const, line }));
}
const vSize = 2 * max + 1;
const v = new Int32Array(vSize);
v.fill(-1);
const trace: Int32Array[] = [];
v[max + 1] = 0;
for (let d = 0; d <= max; d++) {
const snapshot = new Int32Array(v);
trace.push(snapshot);
for (let k = -d; k <= d; k += 2) {
const idx = k + max;
let x: number;
if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
x = v[idx + 1];
} else {
x = v[idx - 1] + 1;
}
let y = x - k;
while (x < n && y < m && a[x] === b[y]) {
x++;
y++;
}
v[idx] = x;
if (x >= n && y >= m) {
return buildEditScript(trace, a, b, max);
}
}
}
return buildEditScript(trace, a, b, max);
}
function buildEditScript(trace: Int32Array[], a: string[], b: string[], max: number): DiffEdit[] {
const edits: DiffEdit[] = [];
let x = a.length;
let y = b.length;
for (let d = trace.length - 1; d > 0; d--) {
const v = trace[d];
const k = x - y;
const idx = k + max;
let prevK: number;
if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
prevK = k + 1;
} else {
prevK = k - 1;
}
const prevIdx = prevK + max;
let prevX = v[prevIdx];
let prevY = prevX - prevK;
// Diagonal (equal lines)
while (x > prevX && y > prevY) {
x--;
y--;
edits.push({ type: 'equal', line: a[x] });
}
if (x === prevX) {
y--;
edits.push({ type: 'insert', line: b[y] });
} else {
x--;
edits.push({ type: 'delete', line: a[x] });
}
}
// Remaining diagonal at d=0
while (x > 0 && y > 0) {
x--;
y--;
edits.push({ type: 'equal', line: a[x] });
}
edits.reverse();
return edits;
}
/**
* Produce a unified diff string and stats from two snapshot texts.
*/
export function diffSnapshots(before: string, after: string): DiffSnapshotData {
const linesA = before.split('\n');
const linesB = after.split('\n');
const edits = myersDiff(linesA, linesB);
let additions = 0;
let removals = 0;
let unchanged = 0;
const diffLines: string[] = [];
for (const edit of edits) {
switch (edit.type) {
case 'equal':
unchanged++;
diffLines.push(` ${edit.line}`);
break;
case 'insert':
additions++;
diffLines.push(`+ ${edit.line}`);
break;
case 'delete':
removals++;
diffLines.push(`- ${edit.line}`);
break;
}
}
return {
diff: diffLines.join('\n'),
additions,
removals,
unchanged,
changed: additions > 0 || removals > 0,
};
}
// --- Image diffing (via browser Canvas API) ---
interface PixelDiffResult {
totalPixels: number;
differentPixels: number;
mismatchPercentage: number;
diffBase64: string;
dimensionMismatch: boolean;
}
const DIFF_ROUTE_PREFIX = 'https://agent-browser-diff.localhost';
/**
* Compare two image buffers using the browser's Canvas API for pixel comparison.
* Uses an isolated blank page to avoid CSP interference or DOM side effects on the
* user's page. Images are served via intercepted routes to avoid large base64 payloads
* through page.evaluate (which can be slow or hit CDP message size limits).
*/
export async function diffScreenshots(
context: BrowserContext,
baselineBuffer: Buffer,
currentBuffer: Buffer,
opts: { threshold?: number; outputPath?: string; baselineMime?: string }
): Promise<DiffScreenshotData> {
const baselineMime = opts.baselineMime ?? 'image/png';
const threshold = opts.threshold ?? 0.1;
const nonce = Math.random().toString(36).slice(2, 10);
const blankUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/index.html`;
const baselineUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/baseline.png`;
const currentUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/current.png`;
const diffPage = await context.newPage();
let blankRouted = false;
let baselineRouted = false;
let currentRouted = false;
try {
await diffPage.route(blankUrl, (route) =>
route.fulfill({ body: '<html><body></body></html>', contentType: 'text/html' })
);
blankRouted = true;
await diffPage.route(baselineUrl, (route) =>
route.fulfill({ body: baselineBuffer, contentType: baselineMime })
);
baselineRouted = true;
await diffPage.route(currentUrl, (route) =>
route.fulfill({ body: currentBuffer, contentType: 'image/png' })
);
currentRouted = true;
await diffPage.goto(blankUrl);
const pixelDiffFn = async (args: {
baselineUrl: string;
currentUrl: string;
threshold: number;
}) => {
const g = globalThis as any;
const doc = g.document;
const Img = g.Image as new () => any;
function loadImage(url: string) {
return new Promise((resolve, reject) => {
const img = new Img();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image'));
img.src = url;
});
}
const [imgA, imgB] = (await Promise.all([
loadImage(args.baselineUrl),
loadImage(args.currentUrl),
])) as any[];
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
const c = doc.createElement('canvas');
c.width = 1;
c.height = 1;
return {
totalPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
differentPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
mismatchPercentage: 100,
diffBase64: c.toDataURL('image/png').split(',')[1],
dimensionMismatch: true,
};
}
const w = imgA.width;
const h = imgA.height;
const canvasA = doc.createElement('canvas');
canvasA.width = w;
canvasA.height = h;
const ctxA = canvasA.getContext('2d')!;
ctxA.drawImage(imgA, 0, 0);
const dataA = ctxA.getImageData(0, 0, w, h).data;
const canvasB = doc.createElement('canvas');
canvasB.width = w;
canvasB.height = h;
const ctxB = canvasB.getContext('2d')!;
ctxB.drawImage(imgB, 0, 0);
const dataB = ctxB.getImageData(0, 0, w, h).data;
const diffCanvas = doc.createElement('canvas');
diffCanvas.width = w;
diffCanvas.height = h;
const ctxDiff = diffCanvas.getContext('2d')!;
const diffImageData = ctxDiff.createImageData(w, h);
const diffData = diffImageData.data;
const maxColorDistance = args.threshold * 255 * Math.sqrt(3);
let differentPixels = 0;
const totalPixels = w * h;
for (let i = 0; i < totalPixels; i++) {
const offset = i * 4;
const rA = dataA[offset],
gA = dataA[offset + 1],
bA = dataA[offset + 2];
const rB = dataB[offset],
gB = dataB[offset + 1],
bB = dataB[offset + 2];
const dr = rA - rB,
dg = gA - gB,
db = bA - bB;
const dist = Math.sqrt(dr * dr + dg * dg + db * db);
if (dist > maxColorDistance) {
differentPixels++;
diffData[offset] = 255;
diffData[offset + 1] = 0;
diffData[offset + 2] = 0;
diffData[offset + 3] = 255;
} else {
diffData[offset] = Math.round(rA * 0.3);
diffData[offset + 1] = Math.round(gA * 0.3);
diffData[offset + 2] = Math.round(bA * 0.3);
diffData[offset + 3] = 255;
}
}
ctxDiff.putImageData(diffImageData, 0, 0);
const diffBase64 = diffCanvas.toDataURL('image/png').split(',')[1];
return {
totalPixels,
differentPixels,
mismatchPercentage: Math.round((differentPixels / totalPixels) * 10000) / 100,
diffBase64,
dimensionMismatch: false,
};
};
const result = (await diffPage.evaluate(pixelDiffFn, {
baselineUrl,
currentUrl,
threshold,
})) as PixelDiffResult;
let outputPath = opts.outputPath;
if (!outputPath) {
const tmpDir = path.join(
process.env.HOME || process.env.USERPROFILE || '/tmp',
'.agent-browser',
'tmp',
'diffs'
);
await mkdir(tmpDir, { recursive: true });
outputPath = path.join(tmpDir, `diff-${Date.now()}.png`);
}
const diffBuffer = Buffer.from(result.diffBase64, 'base64');
await writeFile(outputPath, diffBuffer);
return {
diffPath: outputPath,
totalPixels: result.totalPixels,
differentPixels: result.differentPixels,
mismatchPercentage: result.mismatchPercentage,
match: result.differentPixels === 0,
...(result.dimensionMismatch ? { dimensionMismatch: true } : {}),
};
} finally {
if (blankRouted) await diffPage.unroute(blankUrl).catch(() => {});
if (baselineRouted) await diffPage.unroute(baselineUrl).catch(() => {});
if (currentRouted) await diffPage.unroute(currentUrl).catch(() => {});
await diffPage.close().catch(() => {});
}
}
-106
View File
@@ -1,106 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js';
describe('domain-filter', () => {
describe('isDomainAllowed', () => {
it('should match exact domains', () => {
expect(isDomainAllowed('example.com', ['example.com'])).toBe(true);
expect(isDomainAllowed('github.com', ['github.com'])).toBe(true);
});
it('should reject non-matching domains', () => {
expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false);
expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false);
});
it('should match wildcard patterns', () => {
expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true);
expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true);
});
it('should match bare domain against wildcard pattern', () => {
expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true);
});
it('should reject non-matching wildcard patterns', () => {
expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false);
expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false);
});
it('should return false for empty allowlist', () => {
expect(isDomainAllowed('example.com', [])).toBe(false);
});
it('should match against multiple patterns', () => {
const patterns = ['example.com', '*.github.com', 'vercel.app'];
expect(isDomainAllowed('example.com', patterns)).toBe(true);
expect(isDomainAllowed('api.github.com', patterns)).toBe(true);
expect(isDomainAllowed('vercel.app', patterns)).toBe(true);
expect(isDomainAllowed('evil.com', patterns)).toBe(false);
});
it('should not partially match domain suffixes without wildcard', () => {
expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false);
});
});
describe('parseDomainList', () => {
it('should split comma-separated domains', () => {
expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']);
});
it('should trim whitespace', () => {
expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']);
});
it('should lowercase domains', () => {
expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']);
});
it('should filter empty entries', () => {
expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']);
});
it('should handle empty string', () => {
expect(parseDomainList('')).toEqual([]);
});
it('should preserve wildcard prefixes', () => {
expect(parseDomainList('*.example.com')).toEqual(['*.example.com']);
});
});
describe('buildWebSocketFilterScript', () => {
it('should produce a valid JavaScript IIFE', () => {
const script = buildWebSocketFilterScript(['example.com', '*.github.com']);
expect(script).toContain('_allowedDomains');
expect(script).toContain('"example.com"');
expect(script).toContain('"*.github.com"');
});
it('should embed the domain list as JSON', () => {
const script = buildWebSocketFilterScript(['a.com']);
expect(script).toContain('["a.com"]');
});
it('should include WebSocket, EventSource, and sendBeacon patches', () => {
const script = buildWebSocketFilterScript(['a.com']);
expect(script).toContain('WebSocket');
expect(script).toContain('EventSource');
expect(script).toContain('SecurityError');
expect(script).toContain('sendBeacon');
});
it('should handle empty allowlist', () => {
const script = buildWebSocketFilterScript([]);
expect(script).toContain('[]');
});
it('should include domain matching logic consistent with isDomainAllowed', () => {
const script = buildWebSocketFilterScript(['*.example.com']);
expect(script).toContain('_isDomainAllowed');
expect(script).toContain('slice(1)');
expect(script).toContain('slice(2)');
});
});
});
-156
View File
@@ -1,156 +0,0 @@
import type { BrowserContext, Route } from 'playwright-core';
/**
* Checks whether a hostname matches one of the allowed domain patterns.
* Patterns support exact match ("example.com") and wildcard prefix ("*.example.com").
*/
export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean {
for (const pattern of allowedDomains) {
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1); // ".example.com"
if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) {
return true;
}
} else if (hostname === pattern) {
return true;
}
}
return false;
}
export function parseDomainList(raw: string): string[] {
return raw
.split(',')
.map((d) => d.trim().toLowerCase())
.filter((d) => d.length > 0);
}
/**
* Build the init script source that monkey-patches WebSocket, EventSource,
* and navigator.sendBeacon to block connections to non-allowed domains.
* Exported for testing.
*/
export function buildWebSocketFilterScript(allowedDomains: string[]): string {
const serialized = JSON.stringify(allowedDomains);
return `(function() {
var _allowedDomains = ${serialized};
function _isDomainAllowed(hostname) {
hostname = hostname.toLowerCase();
for (var i = 0; i < _allowedDomains.length; i++) {
var pattern = _allowedDomains[i];
if (pattern.indexOf('*.') === 0) {
var suffix = pattern.slice(1);
if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) {
return true;
}
} else if (hostname === pattern) {
return true;
}
}
return false;
}
function _checkUrl(url) {
try {
var parsed = new URL(url);
return _isDomainAllowed(parsed.hostname);
} catch(e) {
return false;
}
}
if (typeof WebSocket !== 'undefined') {
var _OrigWS = WebSocket;
WebSocket = function(url, protocols) {
if (!_checkUrl(url)) {
throw new DOMException(
'WebSocket connection to ' + url + ' blocked by domain allowlist',
'SecurityError'
);
}
if (protocols !== undefined) {
return new _OrigWS(url, protocols);
}
return new _OrigWS(url);
};
WebSocket.prototype = _OrigWS.prototype;
WebSocket.CONNECTING = _OrigWS.CONNECTING;
WebSocket.OPEN = _OrigWS.OPEN;
WebSocket.CLOSING = _OrigWS.CLOSING;
WebSocket.CLOSED = _OrigWS.CLOSED;
}
if (typeof EventSource !== 'undefined') {
var _OrigES = EventSource;
EventSource = function(url, opts) {
if (!_checkUrl(url)) {
throw new DOMException(
'EventSource connection to ' + url + ' blocked by domain allowlist',
'SecurityError'
);
}
return new _OrigES(url, opts);
};
EventSource.prototype = _OrigES.prototype;
EventSource.CONNECTING = _OrigES.CONNECTING;
EventSource.OPEN = _OrigES.OPEN;
EventSource.CLOSED = _OrigES.CLOSED;
}
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
var _origSendBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(url, data) {
if (!_checkUrl(url)) {
return false;
}
return _origSendBeacon(url, data);
};
}
})();`;
}
/**
* Installs a context-level route that enforces the domain allowlist.
* Both document navigations and sub-resource requests (scripts, images, fetch, etc.)
* to non-allowed domains are blocked, preventing data exfiltration.
* Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources
* but blocked for document navigations.
*
* Also installs an init script that patches WebSocket, EventSource, and
* navigator.sendBeacon to block connections to non-allowed domains. This is
* a best-effort defense: if eval is permitted by action policy, page scripts
* could theoretically restore the originals. Denying the eval action
* category closes that loophole.
*/
export async function installDomainFilter(
context: BrowserContext,
allowedDomains: string[]
): Promise<void> {
if (allowedDomains.length === 0) return;
await context.addInitScript(buildWebSocketFilterScript(allowedDomains));
await context.route('**/*', async (route: Route) => {
const request = route.request();
const urlStr = request.url();
if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) {
if (request.resourceType() === 'document') {
await route.abort('blockedbyclient');
} else {
await route.continue();
}
return;
}
let hostname: string;
try {
hostname = new URL(urlStr).hostname.toLowerCase();
} catch {
await route.abort('blockedbyclient');
return;
}
if (isDomainAllowed(hostname, allowedDomains)) {
await route.continue();
} else {
await route.abort('blockedbyclient');
}
});
}
-474
View File
@@ -1,474 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as crypto from 'crypto';
import {
encryptData,
decryptData,
getEncryptionKey,
getKeyFilePath,
isEncryptedPayload,
ENCRYPTION_KEY_ENV,
IV_LENGTH,
type EncryptedPayload,
} from './encryption.js';
// Mock node:fs to isolate getEncryptionKey from the local filesystem
const mockFs = vi.hoisted(() => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
originals: {} as Pick<typeof import('node:fs'), 'existsSync' | 'readFileSync'>,
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
mockFs.originals = { existsSync: actual.existsSync, readFileSync: actual.readFileSync };
mockFs.existsSync.mockImplementation(actual.existsSync);
mockFs.readFileSync.mockImplementation(actual.readFileSync);
return { ...actual, existsSync: mockFs.existsSync, readFileSync: mockFs.readFileSync };
});
// Generate a valid test key (256 bits = 32 bytes = 64 hex chars)
const generateTestKey = () => crypto.randomBytes(32);
const generateTestKeyHex = () => crypto.randomBytes(32).toString('hex');
describe('encryption', () => {
describe('encryptData / decryptData', () => {
it('should round-trip encrypt and decrypt data correctly', () => {
const key = generateTestKey();
const plaintext = 'Hello, World! This is a test message.';
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should round-trip with complex JSON data', () => {
const key = generateTestKey();
const data = {
cookies: [{ name: 'session', value: 'abc123', domain: '.example.com' }],
localStorage: { theme: 'dark', userId: '12345' },
sessionStorage: {},
};
const plaintext = JSON.stringify(data);
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(JSON.parse(decrypted)).toEqual(data);
});
it('should round-trip with empty string', () => {
const key = generateTestKey();
const plaintext = '';
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should round-trip with unicode characters', () => {
const key = generateTestKey();
const plaintext = '你好世界 🌍 Привет мир émojis: 🔐🔑';
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should round-trip with large data', () => {
const key = generateTestKey();
const plaintext = 'x'.repeat(100000); // 100KB of data
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
});
describe('IV uniqueness', () => {
it('should generate different IVs for each encryption', () => {
const key = generateTestKey();
const plaintext = 'Same message encrypted twice';
const encrypted1 = encryptData(plaintext, key);
const encrypted2 = encryptData(plaintext, key);
// IVs should be different
expect(encrypted1.iv).not.toBe(encrypted2.iv);
// Ciphertext should also be different due to different IVs
expect(encrypted1.data).not.toBe(encrypted2.data);
// Both should decrypt to the same plaintext
expect(decryptData(encrypted1, key)).toBe(plaintext);
expect(decryptData(encrypted2, key)).toBe(plaintext);
});
it('should have correct IV length', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const ivBuffer = Buffer.from(encrypted.iv, 'base64');
expect(ivBuffer.length).toBe(IV_LENGTH);
});
});
describe('authentication (tamper detection)', () => {
it('should throw error when auth tag is tampered', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Tamper with the auth tag
const tamperedAuthTag = Buffer.from(encrypted.authTag, 'base64');
tamperedAuthTag[0] ^= 0xff; // Flip bits
const tamperedPayload: EncryptedPayload = {
...encrypted,
authTag: tamperedAuthTag.toString('base64'),
};
expect(() => decryptData(tamperedPayload, key)).toThrow();
});
it('should throw error when ciphertext is tampered', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Tamper with the ciphertext
const tamperedData = Buffer.from(encrypted.data, 'base64');
tamperedData[0] ^= 0xff; // Flip bits
const tamperedPayload: EncryptedPayload = {
...encrypted,
data: tamperedData.toString('base64'),
};
expect(() => decryptData(tamperedPayload, key)).toThrow();
});
it('should throw error when IV is tampered', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Tamper with the IV
const tamperedIv = Buffer.from(encrypted.iv, 'base64');
tamperedIv[0] ^= 0xff; // Flip bits
const tamperedPayload: EncryptedPayload = {
...encrypted,
iv: tamperedIv.toString('base64'),
};
expect(() => decryptData(tamperedPayload, key)).toThrow();
});
});
describe('wrong key handling', () => {
it('should throw error when decrypting with wrong key', () => {
const key1 = generateTestKey();
const key2 = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key1);
// Try to decrypt with a different key
expect(() => decryptData(encrypted, key2)).toThrow();
});
it('should throw error when key is partially wrong', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Create a key with one byte different
const wrongKey = Buffer.from(key);
wrongKey[0] ^= 0xff;
expect(() => decryptData(encrypted, wrongKey)).toThrow();
});
});
describe('malformed payload detection', () => {
it('should throw error for empty IV', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const malformed: EncryptedPayload = {
...encrypted,
iv: '',
};
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for empty auth tag', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const malformed: EncryptedPayload = {
...encrypted,
authTag: '',
};
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for invalid base64 in IV', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const malformed: EncryptedPayload = {
...encrypted,
iv: '!!!not-valid-base64!!!',
};
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for truncated auth tag', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
// Truncate auth tag to just 4 bytes (minimum allowed, but wrong value)
// This won't match the actual tag, so authentication will fail
const truncatedTag = crypto.randomBytes(4); // Random 4 bytes won't match
const malformed: EncryptedPayload = {
...encrypted,
authTag: truncatedTag.toString('base64'),
};
// Note: With Node.js deprecation warning, very short tags may still be
// accepted but will fail authentication during decipher.final()
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for completely wrong auth tag length', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
// Use a completely wrong auth tag (right length but wrong value)
const wrongTag = crypto.randomBytes(16); // Same length as real tag
const malformed: EncryptedPayload = {
...encrypted,
authTag: wrongTag.toString('base64'),
};
expect(() => decryptData(malformed, key)).toThrow();
});
});
describe('getEncryptionKey', () => {
const originalEnv = process.env[ENCRYPTION_KEY_ENV];
const keyFilePath = getKeyFilePath();
function mockKeyFile(content?: string): void {
const exists = content !== undefined;
mockFs.existsSync.mockImplementation((path: string) => {
if (path === keyFilePath) return exists;
return mockFs.originals.existsSync(path);
});
if (exists) {
mockFs.readFileSync.mockImplementation((path: string, encoding?: string) => {
if (path === keyFilePath) return content;
return mockFs.originals.readFileSync(path, encoding as BufferEncoding);
});
}
}
afterEach(() => {
mockFs.existsSync.mockImplementation(mockFs.originals.existsSync);
mockFs.readFileSync.mockImplementation(mockFs.originals.readFileSync);
if (originalEnv !== undefined) {
process.env[ENCRYPTION_KEY_ENV] = originalEnv;
} else {
delete process.env[ENCRYPTION_KEY_ENV];
}
});
describe('from env var', () => {
beforeEach(() => {
mockKeyFile();
});
it('should return null when env var is not set', () => {
delete process.env[ENCRYPTION_KEY_ENV];
expect(getEncryptionKey()).toBeNull();
});
it('should return null for empty string', () => {
process.env[ENCRYPTION_KEY_ENV] = '';
expect(getEncryptionKey()).toBeNull();
});
it('should return null for invalid hex (too short)', () => {
process.env[ENCRYPTION_KEY_ENV] = 'abc123'; // Only 6 chars, need 64
expect(getEncryptionKey()).toBeNull();
});
it('should return null for invalid hex (too long)', () => {
process.env[ENCRYPTION_KEY_ENV] = 'a'.repeat(128); // 128 chars, need 64
expect(getEncryptionKey()).toBeNull();
});
it('should return null for non-hex characters', () => {
process.env[ENCRYPTION_KEY_ENV] = 'g'.repeat(64); // 'g' is not hex
expect(getEncryptionKey()).toBeNull();
});
it('should return valid key buffer for correct hex string', () => {
const keyHex = generateTestKeyHex();
process.env[ENCRYPTION_KEY_ENV] = keyHex;
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key).toBeInstanceOf(Buffer);
expect(key!.length).toBe(32); // 256 bits
expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
});
it('should accept uppercase hex', () => {
const keyHex = generateTestKeyHex().toUpperCase();
process.env[ENCRYPTION_KEY_ENV] = keyHex;
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key!.length).toBe(32);
});
it('should accept mixed case hex', () => {
const keyHex = generateTestKeyHex();
const mixedCase = keyHex
.split('')
.map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
.join('');
process.env[ENCRYPTION_KEY_ENV] = mixedCase;
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key!.length).toBe(32);
});
});
describe('from key file fallback', () => {
beforeEach(() => {
delete process.env[ENCRYPTION_KEY_ENV];
});
it('should return key when key file exists with valid hex', () => {
const keyHex = generateTestKeyHex();
mockKeyFile(keyHex);
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key).toBeInstanceOf(Buffer);
expect(key!.length).toBe(32);
expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
});
it('should return null when key file does not exist', () => {
mockKeyFile();
expect(getEncryptionKey()).toBeNull();
});
it('should return null when key file contains invalid hex', () => {
mockKeyFile('not-valid-hex');
expect(getEncryptionKey()).toBeNull();
});
});
});
describe('isEncryptedPayload', () => {
it('should return true for valid encrypted payload', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
expect(isEncryptedPayload(encrypted)).toBe(true);
});
it('should return false for null', () => {
expect(isEncryptedPayload(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isEncryptedPayload(undefined)).toBe(false);
});
it('should return false for plain object without encrypted flag', () => {
expect(isEncryptedPayload({ data: 'test' })).toBe(false);
});
it('should return false for object with encrypted: false', () => {
expect(
isEncryptedPayload({
encrypted: false,
version: 1,
iv: 'test',
authTag: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing version', () => {
expect(
isEncryptedPayload({
encrypted: true,
iv: 'test',
authTag: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing iv', () => {
expect(
isEncryptedPayload({
encrypted: true,
version: 1,
authTag: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing authTag', () => {
expect(
isEncryptedPayload({
encrypted: true,
version: 1,
iv: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing data', () => {
expect(
isEncryptedPayload({
encrypted: true,
version: 1,
iv: 'test',
authTag: 'test',
})
).toBe(false);
});
it('should return false for array', () => {
expect(isEncryptedPayload([])).toBe(false);
});
it('should return false for string', () => {
expect(isEncryptedPayload('encrypted')).toBe(false);
});
it('should return false for number', () => {
expect(isEncryptedPayload(42)).toBe(false);
});
});
});
-203
View File
@@ -1,203 +0,0 @@
/**
* Encryption utilities for state file protection using AES-256-GCM.
*/
import * as crypto from 'crypto';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import os from 'node:os';
// ============================================
// Constants
// ============================================
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
export const IV_LENGTH = 12; // 96 bits for GCM
const KEY_FILE_NAME = '.encryption-key';
/**
* Encrypted payload structure.
*/
export interface EncryptedPayload {
version: 1;
encrypted: true;
iv: string; // Base64 encoded
authTag: string; // Base64 encoded
data: string; // Base64 encoded ciphertext
}
export function getKeyFilePath(): string {
return join(os.homedir(), '.agent-browser', KEY_FILE_NAME);
}
/**
* Restrict file permissions to the current user only.
* On Unix, the caller should use `mode: 0o600` when writing. This function
* handles Windows where Node's mode parameter is ignored.
*/
export function restrictFilePermissions(filePath: string): void {
if (os.platform() !== 'win32') return;
try {
execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, {
stdio: 'ignore',
windowsHide: true,
});
} catch {
// Best-effort; may fail in some environments (containers, restricted shells)
}
}
/**
* Restrict directory permissions to the current user only.
* On Unix, the caller should use `mode: 0o700` when creating. This function
* handles Windows where Node's mode parameter is ignored.
*/
export function restrictDirPermissions(dirPath: string): void {
if (os.platform() !== 'win32') return;
try {
execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, {
stdio: 'ignore',
windowsHide: true,
});
} catch {
// Best-effort
}
}
function parseKeyHex(keyHex: string): Buffer | null {
if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null;
return Buffer.from(keyHex.trim(), 'hex');
}
/**
* Get encryption key from environment variable or key file.
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
* Generate with: openssl rand -hex 32
*
* Checks (in order):
* 1. AGENT_BROWSER_ENCRYPTION_KEY env var
* 2. ~/.agent-browser/.encryption-key file
*
* @returns Buffer containing the key, or null if not available
*/
export function getEncryptionKey(): Buffer | null {
const keyHex = process.env[ENCRYPTION_KEY_ENV];
if (keyHex) {
const key = parseKeyHex(keyHex);
if (!key) {
console.warn(
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
`Generate one with: openssl rand -hex 32`
);
return null;
}
return key;
}
const keyFilePath = getKeyFilePath();
if (existsSync(keyFilePath)) {
try {
const fileHex = readFileSync(keyFilePath, 'utf-8');
return parseKeyHex(fileHex);
} catch {
return null;
}
}
return null;
}
/**
* Ensure an encryption key is available, auto-generating one if needed.
* On first call without an existing key, generates a random 256-bit key
* and writes it to ~/.agent-browser/.encryption-key (mode 0600).
*/
export function ensureEncryptionKey(): Buffer {
const existing = getEncryptionKey();
if (existing) return existing;
const key = crypto.randomBytes(32);
const keyHex = key.toString('hex');
const dir = join(os.homedir(), '.agent-browser');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
restrictDirPermissions(dir);
}
const keyFilePath = getKeyFilePath();
writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 });
restrictFilePermissions(keyFilePath);
console.error(
`[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}`
);
return key;
}
/**
* Encrypt data using AES-256-GCM.
* Returns a JSON-serializable payload with IV, auth tag, and encrypted data.
*
* @param plaintext - The string to encrypt
* @param key - The 256-bit encryption key
* @returns Encrypted payload object
*/
export function encryptData(plaintext: string, key: Buffer): EncryptedPayload {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
return {
version: 1,
encrypted: true,
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
data: encrypted.toString('base64'),
};
}
/**
* Decrypt data using AES-256-GCM.
*
* @param payload - The encrypted payload object
* @param key - The 256-bit encryption key
* @returns Decrypted plaintext string
* @throws Error if decryption fails (wrong key, tampered data, etc.)
*/
export function decryptData(payload: EncryptedPayload, key: Buffer): string {
const iv = Buffer.from(payload.iv, 'base64');
const authTag = Buffer.from(payload.authTag, 'base64');
const encryptedData = Buffer.from(payload.data, 'base64');
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Check if a parsed JSON object is an encrypted payload.
*
* @param data - The object to check
* @returns True if the object is a valid encrypted payload
*/
export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
return (
typeof data === 'object' &&
data !== null &&
'encrypted' in data &&
(data as EncryptedPayload).encrypted === true &&
'version' in data &&
'iv' in data &&
'authTag' in data &&
'data' in data
);
}
-26
View File
@@ -1,26 +0,0 @@
export { BrowserManager, getDefaultTimeout } from './browser.js';
export type {
BrowserLaunchOptions,
NavigateOptions,
ScreencastFrame,
ScreencastOptions,
} from './browser.js';
export { IOSManager } from './ios-manager.js';
export { executeCommand } from './actions.js';
export type { Command, LaunchCommand, NavigateCommand, Response } from './types.js';
export {
cleanupSocket,
getAppDir,
getConnectionInfo,
getPidFile,
getPortFile,
getPortForSession,
getSession,
getSocketDir,
getSocketPath,
getStreamPortFile,
isDaemonRunning,
safeWrite,
setSession,
startDaemon,
} from './daemon.js';
-35
View File
@@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import { injectSessionId, stripSessionId } from './inspect-server.js';
describe('injectSessionId', () => {
it('should inject sessionId into a command', () => {
const input = '{"id":1,"method":"DOM.getDocument"}';
const result = JSON.parse(injectSessionId(input, 'abc123'));
expect(result.sessionId).toBe('abc123');
expect(result.method).toBe('DOM.getDocument');
expect(result.id).toBe(1);
});
it('should inject sessionId into an empty object', () => {
const result = JSON.parse(injectSessionId('{}', 'abc'));
expect(result.sessionId).toBe('abc');
});
});
describe('stripSessionId', () => {
it('should remove sessionId from a message', () => {
const input = '{"id":1,"result":{},"sessionId":"abc123"}';
const result = JSON.parse(stripSessionId(input));
expect(result.sessionId).toBeUndefined();
expect(result.id).toBe(1);
});
});
describe('inject then strip roundtrip', () => {
it('should return the original message after inject + strip', () => {
const input = '{"id":42,"method":"Runtime.evaluate"}';
const injected = injectSessionId(input, 'sess1');
const stripped = stripSessionId(injected);
expect(JSON.parse(stripped)).toEqual(JSON.parse(input));
});
});
-240
View File
@@ -1,240 +0,0 @@
import http from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
export interface InspectServerOptions {
chromeHostPort: string;
targetId: string;
chromeWsUrl: string;
}
let nextAttachId = -1000;
export function injectSessionId(json: string, sessionId: string): string {
const msg = JSON.parse(json);
msg.sessionId = sessionId;
return JSON.stringify(msg);
}
export function stripSessionId(json: string): string {
const msg = JSON.parse(json);
delete msg.sessionId;
return JSON.stringify(msg);
}
// The Node.js path opens its own WebSocket to Chrome rather than sharing
// Playwright's internal connection. This avoids interfering with Playwright's
// CDP session management. The Rust/native path takes the opposite approach,
// sharing the daemon's existing browser-level WebSocket via InspectProxyHandle.
export class InspectServer {
private httpServer: http.Server;
private wss: WebSocketServer;
private chromeWs: WebSocket | null = null;
private sessions = new Map<string, WebSocket>();
private pendingAttaches = new Map<number, (sessionId: string | null) => void>();
private _port: number = 0;
constructor(private options: InspectServerOptions) {
this.httpServer = http.createServer(this.handleHttp.bind(this));
this.wss = new WebSocketServer({ server: this.httpServer, path: '/ws' });
this.wss.on('connection', this.handleWsConnection.bind(this));
}
get port(): number {
return this._port;
}
async start(): Promise<void> {
await this.connectChrome();
return new Promise((resolve, reject) => {
this.httpServer.listen(0, '127.0.0.1', () => {
const addr = this.httpServer.address();
if (addr && typeof addr !== 'string') {
this._port = addr.port;
}
resolve();
});
this.httpServer.on('error', reject);
});
}
stop(): void {
for (const [sessionId, devtoolsWs] of this.sessions) {
this.detachSession(sessionId);
devtoolsWs.close();
}
this.sessions.clear();
this.chromeWs?.close();
this.chromeWs = null;
this.wss.close();
this.httpServer.close();
}
private connectChrome(): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.options.chromeWsUrl);
ws.on('open', () => {
this.chromeWs = ws;
resolve();
});
ws.on('error', (err) => {
if (!this.chromeWs) {
reject(new Error(`Chrome WebSocket connection failed: ${err.message}`));
} else {
console.error('[inspect] Chrome WebSocket error:', err.message);
for (const devtoolsWs of this.sessions.values()) {
devtoolsWs.close();
}
this.sessions.clear();
}
});
ws.on('close', () => {
this.chromeWs = null;
for (const devtoolsWs of this.sessions.values()) {
devtoolsWs.close();
}
this.sessions.clear();
});
ws.on('message', (data) => this.handleChromeMessage(data));
});
}
private handleChromeMessage(data: unknown): void {
try {
const text = String(data);
const msg = JSON.parse(text);
// Check if this is a response to a pending attachToTarget request
if (msg.id != null && msg.id < 0) {
const resolve = this.pendingAttaches.get(msg.id);
if (resolve) {
this.pendingAttaches.delete(msg.id);
resolve(msg.result?.sessionId ?? null);
return;
}
}
// Route session-scoped messages to the correct DevTools client
const sessionId: string | undefined = msg.sessionId;
if (!sessionId) return;
const devtoolsWs = this.sessions.get(sessionId);
if (!devtoolsWs || devtoolsWs.readyState !== WebSocket.OPEN) return;
devtoolsWs.send(stripSessionId(text));
} catch (err) {
console.error('[inspect] Chrome message handling error:', err);
}
}
private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
if (req.url === '/' || req.url === '') {
const location = `http://${this.options.chromeHostPort}/devtools/devtools_app.html?ws=127.0.0.1:${this._port}/ws`;
res.writeHead(302, { Location: location, 'Content-Type': 'text/html' });
res.end(`<html><body>Redirecting to <a href="${location}">${location}</a></body></html>`);
return;
}
res.writeHead(404);
res.end();
}
private handleWsConnection(devtoolsWs: WebSocket): void {
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) {
devtoolsWs.close();
return;
}
const attachId = nextAttachId--;
const attachMsg = JSON.stringify({
id: attachId,
method: 'Target.attachToTarget',
params: { targetId: this.options.targetId, flatten: true },
});
// Track the session ID once attach completes; closed by close/error handlers
// that are registered immediately (before the async attach resolves) so
// early disconnects still trigger cleanup.
let sessionId: string | null = null;
devtoolsWs.on('close', () => {
if (sessionId) {
this.sessions.delete(sessionId);
this.detachSession(sessionId);
}
});
devtoolsWs.on('error', () => {
if (sessionId) {
this.sessions.delete(sessionId);
this.detachSession(sessionId);
}
devtoolsWs.close();
});
const messageBuffer: string[] = [];
devtoolsWs.on('message', (data) => {
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
const text = String(data);
if (!sessionId) {
messageBuffer.push(text);
return;
}
try {
this.chromeWs.send(injectSessionId(text, sessionId));
} catch (err) {
console.error('[inspect] DevTools message forwarding error:', err);
}
});
const attachPromise = new Promise<string | null>((resolve) => {
this.pendingAttaches.set(attachId, resolve);
this.chromeWs!.send(attachMsg);
setTimeout(() => {
if (this.pendingAttaches.has(attachId)) {
this.pendingAttaches.delete(attachId);
resolve(null);
}
}, 5000);
});
attachPromise.then((sid) => {
if (!sid) {
console.error('[inspect] Failed to attach to target');
devtoolsWs.close();
return;
}
if (devtoolsWs.readyState !== WebSocket.OPEN) {
this.detachSession(sid);
return;
}
sessionId = sid;
this.sessions.set(sid, devtoolsWs);
for (const buffered of messageBuffer) {
try {
this.chromeWs!.send(injectSessionId(buffered, sid));
} catch (err) {
console.error('[inspect] DevTools message forwarding error:', err);
}
}
messageBuffer.length = 0;
});
}
private detachSession(sessionId: string): void {
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
const detachId = nextAttachId--;
const detachMsg = JSON.stringify({
id: detachId,
method: 'Target.detachFromTarget',
params: { sessionId },
});
try {
this.chromeWs.send(detachMsg);
} catch (err) {
console.error('[inspect] Failed to detach session:', err);
}
}
}
-273
View File
@@ -1,273 +0,0 @@
/**
* iOS command execution - mirrors actions.ts but for iOS Safari via Appium.
* Provides 1:1 command parity where possible.
*/
import type { IOSManager } from './ios-manager.js';
import type { Command, Response } from './types.js';
function successResponse<T>(id: string, data: T): Response<T> {
return { id, success: true, data };
}
function errorResponse(id: string, error: string): Response {
return { id, success: false, error };
}
/**
* Execute a command on the iOS manager
*/
export async function executeIOSCommand(command: Command, manager: IOSManager): Promise<Response> {
const { id, action } = command;
try {
switch (action) {
case 'launch': {
const cmd = command as any;
await manager.launch({
device: cmd.device,
udid: cmd.udid,
});
const info = manager.getDeviceInfo();
return successResponse(id, {
launched: true,
device: info?.name ?? 'iOS Simulator',
udid: info?.udid,
});
}
case 'navigate': {
const cmd = command as any;
const result = await manager.navigate(cmd.url);
return successResponse(id, result);
}
case 'click': {
const cmd = command as any;
await manager.click(cmd.selector);
return successResponse(id, { clicked: true });
}
case 'tap': {
const cmd = command as any;
await manager.tap(cmd.selector);
return successResponse(id, { tapped: true });
}
case 'type': {
const cmd = command as any;
await manager.type(cmd.selector, cmd.text, {
delay: cmd.delay,
clear: cmd.clear,
});
return successResponse(id, { typed: true });
}
case 'fill': {
const cmd = command as any;
await manager.fill(cmd.selector, cmd.value);
return successResponse(id, { filled: true });
}
case 'screenshot': {
const cmd = command as any;
const result = await manager.screenshot({
path: cmd.path,
fullPage: cmd.fullPage,
});
return successResponse(id, result);
}
case 'snapshot': {
const cmd = command as any;
const result = await manager.getSnapshot({
interactive: cmd.interactive,
});
return successResponse(id, { snapshot: result.tree, refs: result.refs });
}
case 'scroll': {
const cmd = command as any;
await manager.scroll({
selector: cmd.selector,
x: cmd.x,
y: cmd.y,
direction: cmd.direction,
amount: cmd.amount,
});
return successResponse(id, { scrolled: true });
}
case 'swipe': {
const cmd = command as any;
await manager.swipe(cmd.direction, { distance: cmd.distance });
return successResponse(id, { swiped: true });
}
case 'evaluate': {
const cmd = command as any;
const result = await manager.evaluate(cmd.script, ...(cmd.args ?? []));
return successResponse(id, { result });
}
case 'wait': {
const cmd = command as any;
await manager.wait({
selector: cmd.selector,
timeout: cmd.timeout,
state: cmd.state,
});
return successResponse(id, { waited: true });
}
case 'press': {
const cmd = command as any;
await manager.press(cmd.key);
return successResponse(id, { pressed: true });
}
case 'hover': {
const cmd = command as any;
await manager.hover(cmd.selector);
return successResponse(id, { hovered: true });
}
case 'content': {
const cmd = command as any;
const html = await manager.getContent(cmd.selector);
return successResponse(id, { html });
}
case 'gettext': {
const cmd = command as any;
const text = await manager.getText(cmd.selector);
return successResponse(id, { text });
}
case 'getattribute': {
const cmd = command as any;
const value = await manager.getAttribute(cmd.selector, cmd.attribute);
return successResponse(id, { value });
}
case 'isvisible': {
const cmd = command as any;
const visible = await manager.isVisible(cmd.selector);
return successResponse(id, { visible });
}
case 'isenabled': {
const cmd = command as any;
const enabled = await manager.isEnabled(cmd.selector);
return successResponse(id, { enabled });
}
case 'url': {
const url = await manager.getUrl();
return successResponse(id, { url });
}
case 'title': {
const title = await manager.getTitle();
return successResponse(id, { title });
}
case 'back': {
await manager.goBack();
return successResponse(id, { navigated: 'back' });
}
case 'forward': {
await manager.goForward();
return successResponse(id, { navigated: 'forward' });
}
case 'reload': {
await manager.reload();
return successResponse(id, { reloaded: true });
}
case 'select': {
const cmd = command as any;
await manager.select(cmd.selector, cmd.values);
return successResponse(id, { selected: true });
}
case 'check': {
const cmd = command as any;
await manager.check(cmd.selector);
return successResponse(id, { checked: true });
}
case 'uncheck': {
const cmd = command as any;
await manager.uncheck(cmd.selector);
return successResponse(id, { unchecked: true });
}
case 'focus': {
const cmd = command as any;
await manager.focus(cmd.selector);
return successResponse(id, { focused: true });
}
case 'clear': {
const cmd = command as any;
await manager.clear(cmd.selector);
return successResponse(id, { cleared: true });
}
case 'count': {
const cmd = command as any;
const count = await manager.count(cmd.selector);
return successResponse(id, { count });
}
case 'boundingbox': {
const cmd = command as any;
const box = await manager.getBoundingBox(cmd.selector);
return successResponse(id, { box });
}
case 'close': {
await manager.close();
return successResponse(id, { closed: true });
}
// iOS-specific: device list
case 'device_list': {
const devices = await manager.listDevices();
return successResponse(id, { devices });
}
// Commands that don't apply to iOS Safari
case 'tab_new':
case 'tab_list':
case 'tab_switch':
case 'tab_close':
case 'window_new':
return errorResponse(
id,
`Command '${action}' is not supported on iOS Safari. Mobile Safari does not support programmatic tab management.`
);
case 'pdf':
return errorResponse(id, 'PDF generation is not supported on iOS Safari.');
case 'screencast_start':
case 'screencast_stop':
return errorResponse(id, 'Screencast is not supported on iOS (requires CDP).');
case 'recording_start':
case 'recording_stop':
case 'recording_restart':
return errorResponse(id, 'Video recording is not yet supported on iOS.');
default:
return errorResponse(id, `Unknown or unsupported iOS command: ${action}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return errorResponse(id, message);
}
}
-157
View File
@@ -1,157 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { IOSManager } from './ios-manager.js';
// Mock node-simctl
vi.mock('node-simctl', () => {
return {
Simctl: class MockSimctl {
async getDevices() {
return {
'iOS 18.0': [
{
name: 'iPhone 16 Pro',
udid: 'TEST-UDID-1234',
state: 'Shutdown',
isAvailable: true,
},
{
name: 'iPhone 16',
udid: 'TEST-UDID-5678',
state: 'Booted',
isAvailable: true,
},
{
name: 'iPad Pro',
udid: 'TEST-UDID-IPAD',
state: 'Shutdown',
isAvailable: true,
},
],
};
}
},
};
});
describe('IOSManager', () => {
let manager: IOSManager;
beforeEach(() => {
manager = new IOSManager();
});
describe('listDevices', () => {
it('should list available iOS simulators', async () => {
const devices = await manager.listDevices();
expect(devices).toHaveLength(3);
expect(devices[0]).toEqual({
name: 'iPhone 16 Pro',
udid: 'TEST-UDID-1234',
state: 'Shutdown',
runtime: 'iOS 18.0',
isAvailable: true,
isRealDevice: false,
});
});
it('should include runtime version for each device', async () => {
const devices = await manager.listDevices();
devices.forEach((device) => {
expect(device.runtime).toBe('iOS 18.0');
});
});
});
describe('isLaunched', () => {
it('should return false when browser is not launched', () => {
expect(manager.isLaunched()).toBe(false);
});
});
describe('getRefData', () => {
it('should return null for unknown refs', () => {
// Access private method via bracket notation for testing
const result = (manager as any).getRefData('@e99');
expect(result).toBeNull();
});
it('should handle @-prefixed refs', () => {
// Set up a ref in the refMap
(manager as any).refMap = {
e1: { selector: 'button', role: 'button', name: 'Submit' },
};
const result = (manager as any).getRefData('@e1');
expect(result).toEqual({ selector: 'button', role: 'button', name: 'Submit' });
});
it('should handle ref= prefixed refs', () => {
(manager as any).refMap = {
e2: { selector: 'a', role: 'link', name: 'Learn more' },
};
const result = (manager as any).getRefData('ref=e2');
expect(result).toEqual({ selector: 'a', role: 'link', name: 'Learn more' });
});
it('should handle bare ref names', () => {
(manager as any).refMap = {
e3: { selector: 'input', role: 'textbox', name: 'Email' },
};
const result = (manager as any).getRefData('e3');
expect(result).toEqual({ selector: 'input', role: 'textbox', name: 'Email' });
});
});
});
describe('IOSManager integration', () => {
// These tests require Appium and iOS Simulator to be available
// They are skipped by default and can be run manually
describe.skip('with real simulator', () => {
let manager: IOSManager;
beforeEach(() => {
// Use real implementation for integration tests
vi.resetModules();
manager = new IOSManager();
});
it('should launch Safari and navigate', async () => {
await manager.launch({ device: 'iPhone 16 Pro' });
expect(manager.isLaunched()).toBe(true);
const result = await manager.navigate('https://example.com');
expect(result.url).toContain('example.com');
expect(result.title).toBe('Example Domain');
await manager.close();
}, 120000);
it('should take screenshots', async () => {
await manager.launch({ device: 'iPhone 16 Pro' });
await manager.navigate('https://example.com');
const result = await manager.screenshot();
expect(result.base64).toBeDefined();
expect(result.base64?.length).toBeGreaterThan(1000);
await manager.close();
}, 120000);
it('should generate snapshots with refs', async () => {
await manager.launch({ device: 'iPhone 16 Pro' });
await manager.navigate('https://example.com');
const snapshot = await manager.getSnapshot();
expect(snapshot.tree).toContain('link');
expect(snapshot.tree).toContain('[ref=e1]');
expect(snapshot.refs.e1).toBeDefined();
expect(snapshot.refs.e1.role).toBe('link');
await manager.close();
}, 120000);
});
});
-1299
View File
File diff suppressed because it is too large Load Diff
-1453
View File
File diff suppressed because it is too large Load Diff
-1162
View File
File diff suppressed because it is too large Load Diff
-640
View File
@@ -1,640 +0,0 @@
/**
* Enhanced snapshot with element refs for deterministic element selection.
*
* This module generates accessibility snapshots with embedded refs that can be
* used to click/fill/interact with elements without re-querying the DOM.
*
* Example output:
* - heading "Example Domain" [ref=e1] [level=1]
* - paragraph: Some text content
* - button "Submit" [ref=e2]
* - textbox "Email" [ref=e3]
*
* Usage:
* agent-browser snapshot # Full snapshot
* agent-browser snapshot -i # Interactive elements only
* agent-browser snapshot --depth 3 # Limit depth
* agent-browser click @e2 # Click element by ref
*/
import type { Page, Locator } from 'playwright-core';
export interface RefMap {
[ref: string]: {
selector: string;
role: string;
name: string;
/** Index for disambiguation when multiple elements have same role+name */
nth?: number;
};
}
export interface EnhancedSnapshot {
tree: string;
refs: RefMap;
}
export interface SnapshotOptions {
/** Only include interactive elements (buttons, links, inputs, etc.) */
interactive?: boolean;
/** Include cursor-interactive elements (cursor:pointer, onclick, tabindex) */
cursor?: boolean;
/** Maximum depth of tree to include (0 = root only) */
maxDepth?: number;
/** Remove structural elements without meaningful content */
compact?: boolean;
/** CSS selector to scope the snapshot */
selector?: string;
}
// Counter for generating refs
let refCounter = 0;
/**
* Reset ref counter (call at start of each snapshot)
*/
export function resetRefs(): void {
refCounter = 0;
}
/**
* Generate next ref ID
*/
function nextRef(): string {
return `e${++refCounter}`;
}
/**
* Roles that are interactive and should get refs
*/
const INTERACTIVE_ROLES = new Set([
'button',
'link',
'textbox',
'checkbox',
'radio',
'combobox',
'listbox',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'treeitem',
]);
/**
* Roles that provide structure/context (get refs for text extraction)
*/
const CONTENT_ROLES = new Set([
'heading',
'cell',
'gridcell',
'columnheader',
'rowheader',
'listitem',
'article',
'region',
'main',
'navigation',
]);
/**
* Roles that are purely structural (can be filtered in compact mode)
*/
const STRUCTURAL_ROLES = new Set([
'generic',
'group',
'list',
'table',
'row',
'rowgroup',
'grid',
'treegrid',
'menu',
'menubar',
'toolbar',
'tablist',
'tree',
'directory',
'document',
'application',
'presentation',
'none',
]);
/**
* Build a selector string for storing in ref map
*/
function buildSelector(role: string, name: string): string {
const escapedName = JSON.stringify(name);
return `getByRole('${role}', { name: ${escapedName}, exact: true })`;
}
/**
* Query the page for clickable elements that might not have proper ARIA roles.
* This finds elements with cursor: pointer or onclick handlers.
*/
async function findCursorInteractiveElements(
page: Page,
selector?: string
): Promise<
Array<{
selector: string;
text: string;
tagName: string;
hasOnClick: boolean;
hasCursorPointer: boolean;
hasTabIndex: boolean;
}>
> {
const rootSelector = selector || 'body';
// Use a string function body to avoid TypeScript transpilation issues
const scriptBody = `(rootSel) => {
const results = [];
// Elements that already have interactive ARIA roles - skip these
const interactiveRoles = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'searchbox',
'slider', 'spinbutton', 'switch', 'tab', 'treeitem'
]);
// Tags that are already interactive by default
const interactiveTags = new Set([
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary'
]);
const root = document.querySelector(rootSel) || document.body;
const allElements = root.querySelectorAll('*');
// Build a unique selector for an element
const buildSelector = (el) => {
const testId = el.getAttribute('data-testid');
if (testId) return '[data-testid="' + testId + '"]';
if (el.id) return '#' + CSS.escape(el.id);
const path = [];
let current = el;
while (current && current !== document.body) {
let sel = current.tagName.toLowerCase();
const classes = Array.from(current.classList).filter(c => c.trim());
if (classes.length > 0) sel += '.' + CSS.escape(classes[0]);
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const matching = siblings.filter(s => {
if (s.tagName !== current.tagName) return false;
if (classes.length > 0 && !s.classList.contains(classes[0])) return false;
return true;
});
if (matching.length > 1) {
const idx = matching.indexOf(current) + 1;
sel += ':nth-of-type(' + idx + ')';
}
}
path.unshift(sel);
current = current.parentElement;
// Stop once the selector uniquely identifies the element (max 10 levels)
if (path.length >= 1) {
try {
const candidate = path.join(' > ');
if (document.querySelectorAll(candidate).length === 1) break;
} catch (e) {
// If selector is invalid, keep building
}
}
if (path.length >= 10) break;
}
return path.join(' > ');
};
for (const el of allElements) {
const tagName = el.tagName.toLowerCase();
if (interactiveTags.has(tagName)) continue;
const role = el.getAttribute('role');
if (role && interactiveRoles.has(role.toLowerCase())) continue;
const computedStyle = getComputedStyle(el);
const hasCursorPointer = computedStyle.cursor === 'pointer';
const hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
const tabIndex = el.getAttribute('tabindex');
const hasTabIndex = tabIndex !== null && tabIndex !== '-1';
if (!hasCursorPointer && !hasOnClick && !hasTabIndex) continue;
// Skip elements that only inherit cursor:pointer from an ancestor
// (the ancestor itself will be captured instead)
if (hasCursorPointer && !hasOnClick && !hasTabIndex) {
const parent = el.parentElement;
if (parent && getComputedStyle(parent).cursor === 'pointer') continue;
}
const text = (el.textContent || '').trim().slice(0, 100);
if (!text) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
results.push({
selector: buildSelector(el),
text,
tagName,
hasOnClick,
hasCursorPointer,
hasTabIndex
});
}
return results;
}`;
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function('return ' + scriptBody)();
return page.evaluate(fn, rootSelector);
}
/**
* Get enhanced snapshot with refs and optional filtering
*/
export async function getEnhancedSnapshot(
page: Page,
options: SnapshotOptions = {}
): Promise<EnhancedSnapshot> {
resetRefs();
const refs: RefMap = {};
// Get ARIA snapshot from Playwright
const locator = options.selector ? page.locator(options.selector) : page.locator(':root');
const ariaTree = await locator.ariaSnapshot();
if (!ariaTree) {
return {
tree: '(empty)',
refs: {},
};
}
// Parse and enhance the ARIA tree
const enhancedTree = processAriaTree(ariaTree, refs, options);
// When cursor flag is set, also find cursor-interactive elements
// that may not have proper ARIA roles
if (options.cursor) {
const cursorElements = await findCursorInteractiveElements(page, options.selector);
// Filter out elements whose text is already captured in the snapshot
const existingTexts = new Set(Object.values(refs).map((r) => r.name.toLowerCase()));
// Also extract quoted strings from the ARIA tree for broader dedup
for (const m of enhancedTree.matchAll(/"([^"]+)"/g)) {
existingTexts.add(m[1].toLowerCase());
}
const additionalLines: string[] = [];
for (const el of cursorElements) {
const elTextLower = el.text.toLowerCase();
// Skip if text already captured in the ARIA tree
if (existingTexts.has(elTextLower)) continue;
existingTexts.add(elTextLower);
const ref = nextRef();
const role = el.hasCursorPointer || el.hasOnClick ? 'clickable' : 'focusable';
refs[ref] = {
selector: el.selector,
role: role,
name: el.text,
};
// Build description of why it's interactive
const hints: string[] = [];
if (el.hasCursorPointer) hints.push('cursor:pointer');
if (el.hasOnClick) hints.push('onclick');
if (el.hasTabIndex) hints.push('tabindex');
additionalLines.push(`- ${role} "${el.text}" [ref=${ref}] [${hints.join(', ')}]`);
}
if (additionalLines.length > 0) {
const separator =
enhancedTree === '(no interactive elements)' ? '' : '\n# Cursor-interactive elements:\n';
const base = enhancedTree === '(no interactive elements)' ? '' : enhancedTree;
return {
tree: base + separator + additionalLines.join('\n'),
refs,
};
}
}
return { tree: enhancedTree, refs };
}
/**
* Track role+name combinations to detect duplicates
*/
interface RoleNameTracker {
counts: Map<string, number>;
/** Maps role+name key to array of ref IDs that use it */
refsByKey: Map<string, string[]>;
getKey(role: string, name?: string): string;
getNextIndex(role: string, name?: string): number;
trackRef(role: string, name: string | undefined, ref: string): void;
/** Get all role+name keys that have duplicates */
getDuplicateKeys(): Set<string>;
}
function createRoleNameTracker(): RoleNameTracker {
const counts = new Map<string, number>();
const refsByKey = new Map<string, string[]>();
return {
counts,
refsByKey,
getKey(role: string, name?: string): string {
return `${role}:${name ?? ''}`;
},
getNextIndex(role: string, name?: string): number {
const key = this.getKey(role, name);
const current = counts.get(key) ?? 0;
counts.set(key, current + 1);
return current;
},
trackRef(role: string, name: string | undefined, ref: string): void {
const key = this.getKey(role, name);
const refs = refsByKey.get(key) ?? [];
refs.push(ref);
refsByKey.set(key, refs);
},
getDuplicateKeys(): Set<string> {
const duplicates = new Set<string>();
for (const [key, refs] of refsByKey) {
if (refs.length > 1) {
duplicates.add(key);
}
}
return duplicates;
},
};
}
/**
* Process ARIA snapshot: add refs and apply filters
*/
function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOptions): string {
const lines = ariaTree.split('\n');
const result: string[] = [];
const tracker = createRoleNameTracker();
// For interactive-only mode, we collect just interactive elements
if (options.interactive) {
for (const line of lines) {
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) continue;
const [, , role, name, suffix] = match;
const roleLower = role.toLowerCase();
if (INTERACTIVE_ROLES.has(roleLower)) {
const ref = nextRef();
const resolvedName = name ?? '';
const nth = tracker.getNextIndex(roleLower, resolvedName);
tracker.trackRef(roleLower, resolvedName, ref);
refs[ref] = {
selector: buildSelector(roleLower, resolvedName),
role: roleLower,
name: resolvedName,
nth, // Always store nth, we'll use it for duplicates
};
let enhanced = `- ${role}`;
if (name) enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`;
// Only show nth in output if it's > 0 (for readability)
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix && suffix.includes('[')) enhanced += suffix;
result.push(enhanced);
}
}
// Post-process: remove nth from refs that don't have duplicates
removeNthFromNonDuplicates(refs, tracker);
return result.join('\n') || '(no interactive elements)';
}
// Normal processing with depth/compact filters
for (const line of lines) {
const processed = processLine(line, refs, options, tracker);
if (processed !== null) {
result.push(processed);
}
}
// Post-process: remove nth from refs that don't have duplicates
removeNthFromNonDuplicates(refs, tracker);
// If compact mode, remove empty structural elements
if (options.compact) {
return compactTree(result.join('\n'));
}
return result.join('\n');
}
/**
* Remove nth from refs that ended up not having duplicates
* This keeps single-element locators simple (no unnecessary .nth(0))
*/
function removeNthFromNonDuplicates(refs: RefMap, tracker: RoleNameTracker): void {
const duplicateKeys = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);
if (!duplicateKeys.has(key)) {
// Not a duplicate, remove nth to keep locator simple
delete refs[ref].nth;
}
}
}
/**
* Get indentation level (number of spaces / 2)
*/
function getIndentLevel(line: string): number {
const match = line.match(/^(\s*)/);
return match ? Math.floor(match[1].length / 2) : 0;
}
/**
* Process a single line: add ref if needed, filter if requested
*/
function processLine(
line: string,
refs: RefMap,
options: SnapshotOptions,
tracker: RoleNameTracker
): string | null {
const depth = getIndentLevel(line);
// Check max depth
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
// Match lines like:
// - button "Submit"
// - heading "Title" [level=1]
// - link "Click me":
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
// Metadata lines (like /url:) or text content
if (options.interactive) {
// In interactive mode, only keep metadata under interactive elements
return null;
}
return line;
}
const [, prefix, role, name, suffix] = match;
const roleLower = role.toLowerCase();
// Skip metadata lines (like /url:)
if (role.startsWith('/')) {
return line;
}
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
const isContent = CONTENT_ROLES.has(roleLower);
const isStructural = STRUCTURAL_ROLES.has(roleLower);
// In interactive-only mode, filter non-interactive elements
if (options.interactive && !isInteractive) {
return null;
}
// In compact mode, skip unnamed structural elements
if (options.compact && isStructural && !name) {
return null;
}
// Add ref for interactive or named content elements
const shouldHaveRef = isInteractive || (isContent && name);
if (shouldHaveRef) {
const ref = nextRef();
// Normalize to "" so unnamed elements get exact-match selectors
const resolvedName = isInteractive ? (name ?? '') : name!;
const nth = tracker.getNextIndex(roleLower, resolvedName);
tracker.trackRef(roleLower, resolvedName, ref);
refs[ref] = {
selector: buildSelector(roleLower, resolvedName),
role: roleLower,
name: resolvedName,
nth, // Always store nth, we'll clean up non-duplicates later
};
// Build enhanced line with ref
let enhanced = `${prefix}${role}`;
if (name) enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`;
// Only show nth in output if it's > 0 (for readability)
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix) enhanced += suffix;
return enhanced;
}
return line;
}
/**
* Remove empty structural branches in compact mode
*/
function compactTree(tree: string): string {
const lines = tree.split('\n');
const result: string[] = [];
// Simple pass: keep lines that have content or refs
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Always keep lines with refs
if (line.includes('[ref=')) {
result.push(line);
continue;
}
// Keep lines with text content (after :)
if (line.includes(':') && !line.endsWith(':')) {
result.push(line);
continue;
}
// Check if this structural element has children with refs
const currentIndent = getIndentLevel(line);
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j++) {
const childIndent = getIndentLevel(lines[j]);
if (childIndent <= currentIndent) break;
if (lines[j].includes('[ref=')) {
hasRelevantChildren = true;
break;
}
}
if (hasRelevantChildren) {
result.push(line);
}
}
return result.join('\n');
}
/**
* Parse a ref from command argument (e.g., "@e1" -> "e1")
*/
export function parseRef(arg: string): string | null {
if (arg.startsWith('@')) {
return arg.slice(1);
}
if (arg.startsWith('ref=')) {
return arg.slice(4);
}
if (/^e\d+$/.test(arg)) {
return arg;
}
return null;
}
/**
* Get snapshot statistics
*/
export function getSnapshotStats(
tree: string,
refs: RefMap
): {
lines: number;
chars: number;
tokens: number;
refs: number;
interactive: number;
} {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return {
lines: tree.split('\n').length,
chars: tree.length,
tokens: Math.ceil(tree.length / 4),
refs: Object.keys(refs).length,
interactive,
};
}
-271
View File
@@ -1,271 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
let tempHome: string;
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
return {
...actual,
homedir: () => tempHome,
};
});
import {
getAutoStateFilePath,
isValidSessionName,
getSessionsDir,
safeHeaderMerge,
listStateFiles,
cleanupExpiredStates,
} from './state-utils.js';
describe('state-utils', () => {
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-test-'));
});
afterEach(() => {
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('isValidSessionName', () => {
it('should accept alphanumeric names', () => {
expect(isValidSessionName('twitter')).toBe(true);
expect(isValidSessionName('Twitter123')).toBe(true);
expect(isValidSessionName('123')).toBe(true);
expect(isValidSessionName('ABC')).toBe(true);
});
it('should accept names with hyphens', () => {
expect(isValidSessionName('my-session')).toBe(true);
expect(isValidSessionName('twitter-prod')).toBe(true);
expect(isValidSessionName('a-b-c-d')).toBe(true);
});
it('should accept names with underscores', () => {
expect(isValidSessionName('my_session')).toBe(true);
expect(isValidSessionName('twitter_prod')).toBe(true);
expect(isValidSessionName('a_b_c_d')).toBe(true);
});
it('should accept mixed valid characters', () => {
expect(isValidSessionName('my-session_123')).toBe(true);
expect(isValidSessionName('Twitter_Prod-v2')).toBe(true);
});
it('should reject empty string', () => {
expect(isValidSessionName('')).toBe(false);
});
it('should reject path traversal attempts', () => {
expect(isValidSessionName('../../../etc/passwd')).toBe(false);
expect(isValidSessionName('..\\..\\windows\\system32')).toBe(false);
expect(isValidSessionName('../parent')).toBe(false);
expect(isValidSessionName('./current')).toBe(false);
});
it('should reject names with slashes', () => {
expect(isValidSessionName('path/to/file')).toBe(false);
expect(isValidSessionName('path\\to\\file')).toBe(false);
expect(isValidSessionName('/absolute/path')).toBe(false);
});
it('should reject names with spaces', () => {
expect(isValidSessionName('my session')).toBe(false);
expect(isValidSessionName(' leading')).toBe(false);
expect(isValidSessionName('trailing ')).toBe(false);
});
it('should reject names with special characters', () => {
expect(isValidSessionName('session@user')).toBe(false);
expect(isValidSessionName('session#1')).toBe(false);
expect(isValidSessionName('session$var')).toBe(false);
expect(isValidSessionName('session%20')).toBe(false);
expect(isValidSessionName('session:name')).toBe(false);
expect(isValidSessionName('session;drop')).toBe(false);
expect(isValidSessionName("session'sql")).toBe(false);
expect(isValidSessionName('session"quote')).toBe(false);
expect(isValidSessionName('session<script>')).toBe(false);
expect(isValidSessionName('session|pipe')).toBe(false);
});
it('should reject names with null bytes', () => {
expect(isValidSessionName('session\x00name')).toBe(false);
});
it('should reject names with newlines', () => {
expect(isValidSessionName('session\nname')).toBe(false);
expect(isValidSessionName('session\rname')).toBe(false);
});
it('should reject Unicode tricks', () => {
// Homograph attacks
expect(isValidSessionName('sеssion')).toBe(false); // Cyrillic 'е'
expect(isValidSessionName('session\u2024')).toBe(false); // One dot leader
expect(isValidSessionName('session\u2025')).toBe(false); // Two dot leader
});
});
describe('getAutoStateFilePath', () => {
it('should return null for empty session name', () => {
expect(getAutoStateFilePath('', 'default')).toBeNull();
});
it('should return valid path for valid inputs', () => {
const result = getAutoStateFilePath('twitter', 'default');
expect(result).not.toBeNull();
expect(result).toContain('twitter-default.json');
expect(result).toContain('.agent-browser');
expect(result).toContain('sessions');
});
it('should throw error for path traversal in session name', () => {
expect(() => getAutoStateFilePath('../etc/passwd', 'default')).toThrow(
/Invalid session name/
);
});
it('should throw error for path traversal in session ID', () => {
expect(() => getAutoStateFilePath('twitter', '../../../etc/passwd')).toThrow(
/Invalid session ID/
);
});
it('should throw error for slashes in session name', () => {
expect(() => getAutoStateFilePath('path/to/file', 'default')).toThrow(/Invalid session name/);
});
it('should throw error for slashes in session ID', () => {
expect(() => getAutoStateFilePath('twitter', 'path/to/file')).toThrow(/Invalid session ID/);
});
it('should throw error for special characters in session name', () => {
expect(() => getAutoStateFilePath('session@evil', 'default')).toThrow(/Invalid session name/);
});
it('should throw error for special characters in session ID', () => {
expect(() => getAutoStateFilePath('twitter', 'id@evil')).toThrow(/Invalid session ID/);
});
it('should accept valid session name with hyphens and underscores', () => {
const result = getAutoStateFilePath('my-session_v2', 'agent_1');
expect(result).not.toBeNull();
expect(result).toContain('my-session_v2-agent_1.json');
});
// Security: Ensure the resulting path is within the sessions directory
it('should always produce path within sessions directory', () => {
const sessionsDir = getSessionsDir();
const result = getAutoStateFilePath('twitter', 'default');
expect(result).not.toBeNull();
expect(result!.startsWith(sessionsDir)).toBe(true);
// Verify the path is actually within the directory (no traversal)
const resolvedPath = path.resolve(result!);
const resolvedSessionsDir = path.resolve(sessionsDir);
expect(resolvedPath.startsWith(resolvedSessionsDir)).toBe(true);
});
});
describe('safeHeaderMerge', () => {
it('should merge two header objects', () => {
const base = { 'Content-Type': 'application/json', Accept: 'text/html' };
const override = { Authorization: 'Bearer token' };
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('application/json');
expect(result['Accept']).toBe('text/html');
expect(result['Authorization']).toBe('Bearer token');
});
it('should allow override to replace base values', () => {
const base = { 'Content-Type': 'text/plain' };
const override = { 'Content-Type': 'application/json' };
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('application/json');
});
it('should filter out __proto__ from base', () => {
const base = { 'Content-Type': 'text/plain', __proto__: 'evil' } as Record<string, string>;
const override = { Accept: 'text/html' };
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('text/plain');
expect(result['Accept']).toBe('text/html');
expect('__proto__' in result).toBe(false);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false);
});
it('should filter out __proto__ from override', () => {
const base = { 'Content-Type': 'text/plain' };
const override = { Accept: 'text/html', __proto__: 'evil' } as Record<string, string>;
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('text/plain');
expect(result['Accept']).toBe('text/html');
expect('__proto__' in result).toBe(false);
});
it('should filter out constructor key', () => {
const base = { constructor: 'evil' } as Record<string, string>;
const override = { Accept: 'text/html' };
const result = safeHeaderMerge(base, override);
expect(result['Accept']).toBe('text/html');
expect('constructor' in result).toBe(false);
});
it('should filter out prototype key', () => {
const base = { prototype: 'evil' } as Record<string, string>;
const override = { Accept: 'text/html' };
const result = safeHeaderMerge(base, override);
expect(result['Accept']).toBe('text/html');
expect('prototype' in result).toBe(false);
});
it('should return null-prototype object', () => {
const base = { 'Content-Type': 'text/plain' };
const override = {};
const result = safeHeaderMerge(base, override);
expect(Object.getPrototypeOf(result)).toBeNull();
});
it('should handle empty objects', () => {
const result = safeHeaderMerge({}, {});
expect(Object.keys(result)).toHaveLength(0);
});
});
describe('listStateFiles', () => {
it('should return empty array when directory does not exist', () => {
const result = listStateFiles();
expect(Array.isArray(result)).toBe(true);
});
});
describe('cleanupExpiredStates', () => {
it('should return empty array for 0 days', () => {
const result = cleanupExpiredStates(0);
expect(result).toEqual([]);
});
it('should return empty array for negative days', () => {
const result = cleanupExpiredStates(-5);
expect(result).toEqual([]);
});
});
});
-224
View File
@@ -1,224 +0,0 @@
/**
* Shared utilities for session state management.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
getEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
type EncryptedPayload,
ENCRYPTION_KEY_ENV,
} from './encryption.js';
/**
* Get the session persistence directory.
* Located at ~/.agent-browser/sessions/
*/
export function getSessionsDir(): string {
return path.join(os.homedir(), '.agent-browser', 'sessions');
}
/**
* Ensure the sessions directory exists with proper permissions.
* Creates directory with mode 0o700 (owner only).
*/
export function ensureSessionsDir(): string {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
}
return sessionsDir;
}
/**
* Validate a session ID to prevent path traversal attacks.
* Only allows alphanumeric characters, hyphens, and underscores.
*/
function isValidSessionId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id);
}
/**
* Validate a session name for safety (no path traversal).
* Only allows alphanumeric characters, dashes, and underscores.
* This validation is critical for security - the daemon reads session names
* from environment variables which can be set by attackers bypassing CLI validation.
*/
export function isValidSessionName(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/**
* Get the auto-save state file path for a session.
* Pattern: {SESSION_NAME}-{SESSION_ID}.json
*
* @param sessionName - The session name (e.g., "twitter")
* @param sessionId - The session ID (e.g., "default" or "agent1")
* @returns Full path to the state file, or null if sessionName is empty
* @throws Error if sessionName or sessionId contains invalid characters (path traversal prevention)
*/
export function getAutoStateFilePath(sessionName: string, sessionId: string): string | null {
if (!sessionName) return null;
// SECURITY: Validate sessionName to prevent path traversal attacks.
// The daemon reads AGENT_BROWSER_SESSION_NAME from environment which
// can be set directly by attackers, bypassing CLI validation.
if (!isValidSessionName(sessionName)) {
throw new Error(
`Invalid session name '${sessionName}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
);
}
if (!isValidSessionId(sessionId)) {
throw new Error(
`Invalid session ID '${sessionId}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
);
}
const sessionsDir = ensureSessionsDir();
return path.join(sessionsDir, `${sessionName}-${sessionId}.json`);
}
/**
* Check if an auto-state file exists for a session.
*/
export function autoStateFileExists(sessionName: string, sessionId: string): boolean {
const filePath = getAutoStateFilePath(sessionName, sessionId);
return filePath ? fs.existsSync(filePath) : false;
}
/**
* Write state data to file, encrypting if encryption key is available.
*
* @param filepath - Path to write the state file
* @param data - State data object to write
* @returns Object indicating whether the file was encrypted
*/
export function writeStateFile(filepath: string, data: object): { encrypted: boolean } {
const key = getEncryptionKey();
const jsonData = JSON.stringify(data, null, 2);
if (key) {
const encrypted = encryptData(jsonData, key);
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
return { encrypted: true };
}
fs.writeFileSync(filepath, jsonData);
return { encrypted: false };
}
/**
* Read state data from file, decrypting if necessary.
*
* @param filepath - Path to the state file
* @returns Object containing the data and whether it was encrypted
* @throws Error if file is encrypted but no key is available
*/
export function readStateFile(filepath: string): { data: object; wasEncrypted: boolean } {
const content = fs.readFileSync(filepath, 'utf-8');
const parsed = JSON.parse(content);
if (isEncryptedPayload(parsed)) {
const key = getEncryptionKey();
if (!key) {
throw new Error(
`State file is encrypted but ${ENCRYPTION_KEY_ENV} is not set. ` +
`Set the environment variable to decrypt.`
);
}
const decrypted = decryptData(parsed, key);
return { data: JSON.parse(decrypted), wasEncrypted: true };
}
return { data: parsed, wasEncrypted: false };
}
/**
* List all state files in the sessions directory.
* @returns Array of filenames ending in .json
*/
export function listStateFiles(): string[] {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
return [];
}
return fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
}
/**
* Clean up state files older than specified days.
* @param days - Maximum age in days (files older than this are deleted)
* @returns Array of deleted filenames
*/
export function cleanupExpiredStates(days: number): string[] {
if (days <= 0) return [];
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
return [];
}
const now = Date.now();
const maxAge = days * 24 * 60 * 60 * 1000;
const deleted: string[] = [];
const files = listStateFiles();
for (const file of files) {
const filepath = path.join(sessionsDir, file);
try {
const stats = fs.statSync(filepath);
if (now - stats.mtime.getTime() > maxAge) {
fs.unlinkSync(filepath);
deleted.push(file);
}
} catch {
// Ignore individual file errors
}
}
return deleted;
}
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
/**
* Safely merge headers without prototype pollution risk.
* Filters out dangerous keys like __proto__, constructor, prototype.
* @param base - Base headers object
* @param override - Headers to merge (takes precedence)
* @returns Merged headers object (null-prototype)
*/
export function safeHeaderMerge(
base: Record<string, string>,
override: Record<string, string>
): Record<string, string> {
const result: Record<string, string> = Object.create(null);
for (const [key, value] of Object.entries(base)) {
if (!DANGEROUS_KEYS.includes(key)) {
result[key] = value;
}
}
for (const [key, value] of Object.entries(override)) {
if (!DANGEROUS_KEYS.includes(key)) {
result[key] = value;
}
}
return result;
}
// Re-export encryption utilities
export {
getEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
type EncryptedPayload,
ENCRYPTION_KEY_ENV,
};
-64
View File
@@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isAllowedOrigin } from './stream-server.js';
describe('isAllowedOrigin', () => {
describe('allowed origins', () => {
it('should allow connections with no origin (CLI tools)', () => {
expect(isAllowedOrigin(undefined)).toBe(true);
});
it('should allow empty string origin', () => {
expect(isAllowedOrigin('')).toBe(true);
});
it('should allow file:// origins', () => {
expect(isAllowedOrigin('file:///path/to/viewer.html')).toBe(true);
expect(isAllowedOrigin('file:///C:/Users/user/viewer.html')).toBe(true);
});
it('should allow http://localhost origins', () => {
expect(isAllowedOrigin('http://localhost')).toBe(true);
expect(isAllowedOrigin('http://localhost:3000')).toBe(true);
expect(isAllowedOrigin('http://localhost:9223')).toBe(true);
expect(isAllowedOrigin('http://localhost:8080')).toBe(true);
});
it('should allow https://localhost origins', () => {
expect(isAllowedOrigin('https://localhost')).toBe(true);
expect(isAllowedOrigin('https://localhost:3000')).toBe(true);
});
it('should allow http://127.0.0.1 origins', () => {
expect(isAllowedOrigin('http://127.0.0.1')).toBe(true);
expect(isAllowedOrigin('http://127.0.0.1:3000')).toBe(true);
expect(isAllowedOrigin('http://127.0.0.1:9223')).toBe(true);
});
it('should allow IPv6 loopback origins', () => {
expect(isAllowedOrigin('http://[::1]')).toBe(true);
expect(isAllowedOrigin('http://[::1]:3000')).toBe(true);
});
});
describe('rejected origins', () => {
it('should reject remote origins', () => {
expect(isAllowedOrigin('https://evil.com')).toBe(false);
expect(isAllowedOrigin('http://attacker.local:8080')).toBe(false);
expect(isAllowedOrigin('https://example.com')).toBe(false);
});
it('should reject origins with localhost in path but not hostname', () => {
expect(isAllowedOrigin('https://evil.com/localhost')).toBe(false);
});
it('should reject origins that look like localhost but are not', () => {
expect(isAllowedOrigin('http://localhost.evil.com')).toBe(false);
expect(isAllowedOrigin('http://not-localhost:3000')).toBe(false);
});
it('should reject invalid origin URLs', () => {
expect(isAllowedOrigin('not-a-url')).toBe(false);
expect(isAllowedOrigin('://missing-scheme')).toBe(false);
});
});
});
-411
View File
@@ -1,411 +0,0 @@
import { WebSocketServer, WebSocket } from 'ws';
import type { BrowserManager, ScreencastFrame } from './browser.js';
import { setScreencastFrameCallback } from './actions.js';
/**
* Check whether a WebSocket connection origin should be allowed.
* Allows: no origin (CLI tools), file:// origins, and localhost/loopback origins.
* Rejects: all other origins (prevents malicious web pages from connecting).
*/
export function isAllowedOrigin(origin: string | undefined): boolean {
// Allow connections with no origin (non-browser clients like CLI tools)
if (!origin) {
return true;
}
// Allow file:// origins (local HTML files)
if (origin.startsWith('file://')) {
return true;
}
// Allow localhost/loopback origins (browser-based stream viewers)
try {
const url = new URL(origin);
const host = url.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return true;
}
} catch {
// Invalid origin URL - reject
}
return false;
}
// Message types for WebSocket communication
export interface FrameMessage {
type: 'frame';
data: string; // base64 encoded image
metadata: {
offsetTop: number;
pageScaleFactor: number;
deviceWidth: number;
deviceHeight: number;
scrollOffsetX: number;
scrollOffsetY: number;
timestamp?: number;
};
}
export interface InputMouseMessage {
type: 'input_mouse';
eventType: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';
x: number;
y: number;
button?: 'left' | 'right' | 'middle' | 'none';
clickCount?: number;
deltaX?: number;
deltaY?: number;
modifiers?: number;
}
export interface InputKeyboardMessage {
type: 'input_keyboard';
eventType: 'keyDown' | 'keyUp' | 'char';
key?: string;
code?: string;
text?: string;
modifiers?: number;
}
export interface InputTouchMessage {
type: 'input_touch';
eventType: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel';
touchPoints: Array<{ x: number; y: number; id?: number }>;
modifiers?: number;
}
export interface StatusMessage {
type: 'status';
connected: boolean;
screencasting: boolean;
viewportWidth?: number;
viewportHeight?: number;
}
export interface ErrorMessage {
type: 'error';
message: string;
}
export type StreamMessage =
| FrameMessage
| InputMouseMessage
| InputKeyboardMessage
| InputTouchMessage
| StatusMessage
| ErrorMessage;
/**
* WebSocket server for streaming browser viewport and receiving input
*/
export class StreamServer {
private wss: WebSocketServer | null = null;
private clients: Set<WebSocket> = new Set();
private browser: BrowserManager;
private port: number;
private isScreencasting: boolean = false;
constructor(browser: BrowserManager, port: number = 9223) {
this.browser = browser;
this.port = port;
}
/**
* Start the WebSocket server
*/
start(): Promise<void> {
return new Promise((resolve, reject) => {
try {
// SECURITY: Bind to localhost only to prevent network exposure.
// The stream server allows direct input injection (mouse, keyboard, touch)
// which would be a critical security risk if exposed to the network.
this.wss = new WebSocketServer({
port: this.port,
host: '127.0.0.1',
// Security: Reject cross-origin WebSocket connections from untrusted origins.
// This prevents malicious web pages from connecting and injecting input events.
// Localhost origins are allowed so browser-based stream viewers can connect.
verifyClient: (info: {
origin: string;
secure: boolean;
req: import('http').IncomingMessage;
}) => {
if (isAllowedOrigin(info.origin)) {
return true;
}
console.log(`[StreamServer] Rejected connection from origin: ${info.origin}`);
return false;
},
});
this.wss.on('connection', (ws) => {
this.handleConnection(ws);
});
this.wss.on('error', (error) => {
console.error('[StreamServer] WebSocket error:', error);
reject(error);
});
this.wss.on('listening', () => {
console.log(`[StreamServer] Listening on port ${this.port}`);
// Set up the screencast frame callback
setScreencastFrameCallback((frame) => {
this.broadcastFrame(frame);
});
resolve();
});
} catch (error) {
reject(error);
}
});
}
/**
* Stop the WebSocket server
*/
async stop(): Promise<void> {
// Stop screencasting
if (this.isScreencasting) {
await this.stopScreencast();
}
// Clear the callback
setScreencastFrameCallback(null);
// Close all clients
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close the server
if (this.wss) {
return new Promise((resolve) => {
this.wss!.close(() => {
this.wss = null;
resolve();
});
});
}
}
/**
* Handle a new WebSocket connection
*/
private handleConnection(ws: WebSocket): void {
console.log('[StreamServer] Client connected');
this.clients.add(ws);
// Send initial status
this.sendStatus(ws);
// Start screencasting if this is the first client
if (this.clients.size === 1 && !this.isScreencasting) {
this.startScreencast().catch((error) => {
console.error('[StreamServer] Failed to start screencast:', error);
this.sendError(ws, error.message);
});
}
// Handle messages from client
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as StreamMessage;
this.handleMessage(message, ws);
} catch (error) {
console.error('[StreamServer] Failed to parse message:', error);
}
});
// Handle client disconnect
ws.on('close', () => {
console.log('[StreamServer] Client disconnected');
this.clients.delete(ws);
// Stop screencasting if no more clients
if (this.clients.size === 0 && this.isScreencasting) {
this.stopScreencast().catch((error) => {
console.error('[StreamServer] Failed to stop screencast:', error);
});
}
});
ws.on('error', (error) => {
console.error('[StreamServer] Client error:', error);
this.clients.delete(ws);
});
}
/**
* Handle incoming messages from clients
*/
private async handleMessage(message: StreamMessage, ws: WebSocket): Promise<void> {
try {
switch (message.type) {
case 'input_mouse':
await this.browser.injectMouseEvent({
type: message.eventType,
x: message.x,
y: message.y,
button: message.button,
clickCount: message.clickCount,
deltaX: message.deltaX,
deltaY: message.deltaY,
modifiers: message.modifiers,
});
break;
case 'input_keyboard':
await this.browser.injectKeyboardEvent({
type: message.eventType,
key: message.key,
code: message.code,
text: message.text,
modifiers: message.modifiers,
});
break;
case 'input_touch':
await this.browser.injectTouchEvent({
type: message.eventType,
touchPoints: message.touchPoints,
modifiers: message.modifiers,
});
break;
case 'status':
// Client is requesting status
this.sendStatus(ws);
break;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.sendError(ws, errorMessage);
}
}
/**
* Broadcast a frame to all connected clients
*/
private broadcastFrame(frame: ScreencastFrame): void {
const message: FrameMessage = {
type: 'frame',
data: frame.data,
metadata: frame.metadata,
};
const payload = JSON.stringify(message);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
/**
* Send status to a client
*/
private sendStatus(ws: WebSocket): void {
let viewportWidth: number | undefined;
let viewportHeight: number | undefined;
try {
const page = this.browser.getPage();
const viewport = page.viewportSize();
viewportWidth = viewport?.width;
viewportHeight = viewport?.height;
} catch {
// Browser not launched yet
}
const message: StatusMessage = {
type: 'status',
connected: true,
screencasting: this.isScreencasting,
viewportWidth,
viewportHeight,
};
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Send an error to a client
*/
private sendError(ws: WebSocket, errorMessage: string): void {
const message: ErrorMessage = {
type: 'error',
message: errorMessage,
};
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Start screencasting
*/
private async startScreencast(): Promise<void> {
// Set flag immediately to prevent race conditions with concurrent calls
if (this.isScreencasting) return;
this.isScreencasting = true;
try {
// Check if browser is launched
if (!this.browser.isLaunched()) {
throw new Error('Browser not launched');
}
await this.browser.startScreencast((frame) => this.broadcastFrame(frame), {
format: 'jpeg',
quality: 80,
maxWidth: 1280,
maxHeight: 720,
everyNthFrame: 1,
});
// Notify all clients
for (const client of this.clients) {
this.sendStatus(client);
}
} catch (error) {
// Reset flag on failure so caller can retry
this.isScreencasting = false;
throw error;
}
}
/**
* Stop screencasting
*/
private async stopScreencast(): Promise<void> {
if (!this.isScreencasting) return;
await this.browser.stopScreencast();
this.isScreencasting = false;
// Notify all clients
for (const client of this.clients) {
this.sendStatus(client);
}
}
/**
* Get the port the server is running on
*/
getPort(): number {
return this.port;
}
/**
* Get the number of connected clients
*/
getClientCount(): number {
return this.clients.size;
}
}
-1303
View File
File diff suppressed because it is too large Load Diff
-317
View File
@@ -1,317 +0,0 @@
import type { BenchmarkCommand, Scenario } from "./scenarios.js";
// ---------------------------------------------------------------------------
// HTML generators for realistic pages with complex DOM structures
// ---------------------------------------------------------------------------
function generateArticlePage(): string {
const paragraphs = Array.from({ length: 30 }, (_, i) => {
const words = Array.from(
{ length: 40 + (i % 5) * 10 },
(_, w) => ["the", "quick", "browser", "engine", "renders", "content", "across", "multiple", "layout", "passes", "while", "handling", "style", "recalculations", "and", "DOM", "mutations"][w % 17],
).join(" ");
return `<p class="article-p">${words}</p>`;
});
const comments = Array.from(
{ length: 40 },
(_, i) =>
`<div class="comment" data-id="${i}">` +
`<div class="comment-header"><span class="author">User ${i}</span><time>2025-01-${String(i % 28 + 1).padStart(2, "0")}</time></div>` +
`<div class="comment-body"><p>This is comment number ${i + 1} with some discussion text.</p></div>` +
`<div class="comment-actions"><button class="reply-btn">Reply</button><button class="like-btn">Like</button></div>` +
`</div>`,
);
const sidebar = Array.from(
{ length: 20 },
(_, i) =>
`<li class="sidebar-item"><a href="#section-${i}">Related Article ${i + 1}: A Longer Title Here</a></li>`,
);
return [
"<html><head><title>Benchmark Article</title>",
"<style>",
"body{font-family:system-ui;margin:0;padding:0;display:grid;grid-template-columns:1fr 300px;gap:20px;max-width:1200px;margin:0 auto}",
".article{padding:20px}.sidebar{padding:20px;border-left:1px solid #ddd}",
".comment{border:1px solid #eee;padding:12px;margin:8px 0;border-radius:4px}",
".comment-header{display:flex;justify-content:space-between;font-size:14px;color:#666}",
".nav{display:flex;gap:16px;padding:12px 20px;background:#f5f5f5;grid-column:1/-1}",
".tag{display:inline-block;padding:2px 8px;background:#e0e7ff;border-radius:12px;font-size:12px;margin:2px}",
"</style></head><body>",
`<nav class="nav">${Array.from({ length: 8 }, (_, i) => `<a href="#nav-${i}">Section ${i + 1}</a>`).join("")}</nav>`,
'<div class="article">',
"<h1>Understanding Modern Browser Engine Architecture</h1>",
'<div class="meta"><span class="author">Dr. Smith</span> | <time>2025-03-15</time> | <span>15 min read</span></div>',
`<div class="tags">${Array.from({ length: 6 }, (_, i) => `<span class="tag">tag-${i + 1}</span>`).join("")}</div>`,
"<h2>Introduction</h2>",
...paragraphs.slice(0, 5),
"<h2>Core Concepts</h2>",
...paragraphs.slice(5, 12),
'<blockquote>"Performance is not just about speed, it is about efficiency." - Anonymous</blockquote>',
"<h2>Implementation Details</h2>",
...paragraphs.slice(12, 20),
"<h3>Subsection A</h3>",
...paragraphs.slice(20, 25),
"<h3>Subsection B</h3>",
...paragraphs.slice(25),
"<h2>Comments</h2>",
'<div class="comments">',
...comments,
"</div></div>",
'<div class="sidebar">',
"<h3>Related Articles</h3>",
`<ul>${sidebar.join("")}</ul>`,
"<h3>Archives</h3>",
`<ul>${Array.from({ length: 12 }, (_, i) => `<li><a href="#month-${i}">Month ${i + 1}, 2025</a></li>`).join("")}</ul>`,
"</div>",
"</body></html>",
].join("");
}
function generateDataTablePage(): string {
const headerCells = [
"ID", "Name", "Email", "Department", "Role", "Status", "Joined", "Last Active",
];
const header = `<tr>${headerCells.map((h) => `<th>${h}</th>`).join("")}</tr>`;
const rows = Array.from({ length: 200 }, (_, i) => {
const dept = ["Engineering", "Design", "Marketing", "Sales", "Support"][i % 5];
const role = ["Admin", "Manager", "Member", "Viewer"][i % 4];
const status = ["Active", "Inactive", "Pending"][i % 3];
return (
`<tr data-row="${i}">` +
`<td>${i + 1}</td>` +
`<td><a href="#user-${i}">User ${i + 1}</a></td>` +
`<td>user${i + 1}@example.com</td>` +
`<td>${dept}</td>` +
`<td><span class="badge badge-${role.toLowerCase()}">${role}</span></td>` +
`<td><span class="status status-${status.toLowerCase()}">${status}</span></td>` +
`<td>2024-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}</td>` +
`<td>${i % 3 === 0 ? "Today" : i % 3 === 1 ? "Yesterday" : "Last week"}</td>` +
`</tr>`
);
});
return [
"<html><head><title>Benchmark Table</title>",
"<style>",
"body{font-family:system-ui;margin:20px}",
"table{width:100%;border-collapse:collapse}",
"th,td{padding:8px 12px;border:1px solid #ddd;text-align:left}",
"th{background:#f5f5f5;font-weight:600;position:sticky;top:0}",
"tr:nth-child(even){background:#fafafa}",
".badge{padding:2px 8px;border-radius:4px;font-size:12px}",
".toolbar{display:flex;gap:12px;margin-bottom:16px;align-items:center}",
"input,select,button{padding:6px 12px;border:1px solid #ccc;border-radius:4px}",
"</style></head><body>",
"<h1>User Management Dashboard</h1>",
'<div class="toolbar">',
'<input id="search" type="text" placeholder="Search users...">',
'<select id="dept-filter"><option value="">All Departments</option><option value="eng">Engineering</option><option value="des">Design</option></select>',
'<select id="status-filter"><option value="">All Statuses</option><option value="active">Active</option><option value="inactive">Inactive</option></select>',
'<button id="add-user">Add User</button>',
'<span id="count">Showing 200 users</span>',
"</div>",
`<table><thead>${header}</thead><tbody>${rows.join("")}</tbody></table>`,
'<div class="pagination">',
...Array.from({ length: 10 }, (_, i) => `<button class="page-btn" data-page="${i + 1}">${i + 1}</button>`),
"</div>",
"</body></html>",
].join("");
}
function generateNestedPage(): string {
function nest(depth: number, breadth: number, prefix: string): string {
if (depth === 0) {
return `<span class="leaf" data-path="${prefix}">Leaf node at ${prefix}</span>`;
}
const children = Array.from(
{ length: breadth },
(_, i) =>
`<div class="node depth-${depth}" data-depth="${depth}" data-idx="${i}">` +
`<div class="node-header"><strong>Section ${prefix}.${i + 1}</strong> <em>(depth ${depth})</em></div>` +
`<div class="node-content">${nest(depth - 1, Math.max(2, breadth - 1), `${prefix}.${i + 1}`)}</div>` +
`</div>`,
);
return children.join("");
}
return [
"<html><head><title>Benchmark Nested</title>",
"<style>",
"body{font-family:system-ui;margin:20px}",
".node{border-left:2px solid #ddd;padding-left:16px;margin:4px 0}",
".node-header{padding:4px 0;cursor:pointer}",
".leaf{display:block;padding:2px 8px;background:#f0f9ff;margin:2px 0;border-radius:2px}",
"</style></head><body>",
"<h1>Deeply Nested Document Structure</h1>",
nest(7, 3, "root"),
"</body></html>",
].join("");
}
function generateDashboardPage(): string {
const cards = Array.from(
{ length: 12 },
(_, i) =>
`<div class="card" data-card="${i}">` +
`<div class="card-title">Metric ${i + 1}</div>` +
`<div class="card-value">${Math.floor(Math.random() * 10000)}</div>` +
`<div class="card-trend ${i % 2 === 0 ? "up" : "down"}">${i % 2 === 0 ? "+" : "-"}${(Math.random() * 20).toFixed(1)}%</div>` +
`</div>`,
);
const chartBars = Array.from(
{ length: 24 },
(_, i) => {
const h = 20 + (i * 7 + 13) % 80;
return `<div class="bar" style="height:${h}%" data-hour="${i}"><span class="bar-label">${String(i).padStart(2, "0")}:00</span></div>`;
},
);
const logRows = Array.from(
{ length: 100 },
(_, i) => {
const level = ["INFO", "WARN", "ERROR", "DEBUG"][i % 4];
return (
`<tr class="log-${level.toLowerCase()}" data-log="${i}">` +
`<td>${new Date(2025, 0, 1, i % 24, i % 60).toISOString()}</td>` +
`<td><span class="level level-${level.toLowerCase()}">${level}</span></td>` +
`<td>Service ${["auth", "api", "worker", "cache", "db"][i % 5]}</td>` +
`<td>Log message number ${i + 1}: operation completed in ${(Math.random() * 1000).toFixed(0)}ms</td>` +
`</tr>`
);
},
);
return [
"<html><head><title>Benchmark Dashboard</title>",
"<style>",
"body{font-family:system-ui;margin:0;background:#f5f5f5}",
".header{background:#1a1a2e;color:white;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}",
".grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;padding:24px}",
".card{background:white;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
".card-value{font-size:28px;font-weight:700;margin:8px 0}",
".card-trend.up{color:#16a34a}.card-trend.down{color:#dc2626}",
".chart-area{background:white;margin:0 24px;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
".bars{display:flex;align-items:flex-end;gap:4px;height:200px}",
".bar{background:#3b82f6;flex:1;border-radius:2px 2px 0 0;position:relative;min-width:8px}",
".log-table{margin:24px;background:white;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1);overflow:hidden}",
"table{width:100%;border-collapse:collapse;font-size:13px}",
"th,td{padding:6px 12px;border-bottom:1px solid #eee;text-align:left}",
"th{background:#f9fafb;font-weight:600}",
".tabs{display:flex;gap:0;margin:24px 24px 0}",
".tab{padding:8px 20px;background:#e5e7eb;cursor:pointer;border-radius:6px 6px 0 0}",
".tab.active{background:white}",
"</style></head><body>",
'<div class="header"><h1>Operations Dashboard</h1><div><input id="dash-search" placeholder="Search..." type="text"><button id="refresh">Refresh</button></div></div>',
`<div class="grid">${cards.join("")}</div>`,
'<div class="tabs"><div class="tab active">Hourly</div><div class="tab">Daily</div><div class="tab">Weekly</div></div>',
`<div class="chart-area"><h3>Request Volume</h3><div class="bars">${chartBars.join("")}</div></div>`,
'<div class="log-table">',
"<h3 style='padding:16px 12px 0'>Recent Logs</h3>",
`<table><thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th></tr></thead><tbody>${logRows.join("")}</tbody></table>`,
"</div>",
"</body></html>",
].join("");
}
// ---------------------------------------------------------------------------
// Pre-build HTML strings and injection commands
// ---------------------------------------------------------------------------
const ARTICLE_HTML = generateArticlePage();
const TABLE_HTML = generateDataTablePage();
const NESTED_HTML = generateNestedPage();
const DASHBOARD_HTML = generateDashboardPage();
function injectCmd(id: string, html: string): BenchmarkCommand {
return {
id,
action: "evaluate",
script: `document.open(); document.write(${JSON.stringify(html)}); document.close(); 'ok'`,
};
}
function setupPage(html: string, tag: string): BenchmarkCommand[] {
return [
{ id: `${tag}-nav`, action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
injectCmd(`${tag}-inject`, html),
];
}
// ---------------------------------------------------------------------------
// Engine-specific scenarios: complex pages that stress real-world workloads
// ---------------------------------------------------------------------------
export const engineScenarios: Scenario[] = [
{
name: "article-snapshot",
description: "Snapshot a realistic article page (~800 DOM nodes, 30 paragraphs, 40 comments)",
setup: setupPage(ARTICLE_HTML, "art"),
commands: [{ id: "snap", action: "snapshot" }],
},
{
name: "table-snapshot",
description: "Snapshot a data table with 200 rows and 8 columns",
setup: setupPage(TABLE_HTML, "tbl"),
commands: [{ id: "snap", action: "snapshot" }],
},
{
name: "nested-snapshot",
description: "Snapshot a deeply nested DOM tree (7 levels, ~3000 nodes)",
setup: setupPage(NESTED_HTML, "nest"),
commands: [{ id: "snap", action: "snapshot" }],
},
{
name: "dashboard-snap",
description: "Snapshot an operations dashboard with cards, chart, and 100 log rows",
setup: setupPage(DASHBOARD_HTML, "dash"),
commands: [{ id: "snap", action: "snapshot" }],
},
{
name: "article-inject",
description: "Write a full article page into the DOM (measures parse + layout)",
setup: [
{ id: "ai-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
],
commands: [injectCmd("ai-write", ARTICLE_HTML)],
},
{
name: "table-query",
description: "Evaluate a querySelectorAll across a large table",
setup: setupPage(TABLE_HTML, "tq"),
commands: [
{
id: "query",
action: "evaluate",
script: "document.querySelectorAll('tr[data-row]').length + ' rows, ' + document.querySelectorAll('td').length + ' cells'",
},
],
},
{
name: "dashboard-workflow",
description: "Full agent workflow on complex dashboard: snapshot, click, fill, eval, screenshot",
setup: setupPage(DASHBOARD_HTML, "dw"),
commands: [
{ id: "dw-snap", action: "snapshot" },
{ id: "dw-fill", action: "fill", selector: "#dash-search", value: "error logs" },
{ id: "dw-click", action: "click", selector: "#refresh" },
{ id: "dw-eval", action: "evaluate", script: "document.querySelectorAll('.card').length + ' cards'" },
{ id: "dw-ss", action: "screenshot" },
],
},
{
name: "nested-eval",
description: "Recursive DOM traversal via evaluate on deeply nested tree",
setup: setupPage(NESTED_HTML, "ne"),
commands: [
{
id: "walk",
action: "evaluate",
script: "(function(){let c=0;const w=n=>{c++;for(const ch of n.children)w(ch);};w(document.body);return c+' nodes';})()",
},
],
},
];
-253
View File
@@ -1,253 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Understanding Modern Browser Engine Architecture</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a1a2e; background: #fff; }
.nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; background: #1a1a2e; color: #fff; position: sticky; top: 0; z-index: 100; }
.nav a { color: #94a3b8; text-decoration: none; font-size: 14px; transition: color 0.2s; }
.nav a:hover { color: #fff; }
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 40px; max-width: 1200px; margin: 0 auto; padding: 40px 24px; }
.article { min-width: 0; }
.article h1 { font-size: 2.2rem; line-height: 1.2; margin-bottom: 16px; }
.meta { display: flex; gap: 16px; color: #64748b; font-size: 14px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e2e8f0; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 32px; }
.tag { display: inline-block; padding: 4px 12px; background: #e0e7ff; color: #3730a3; border-radius: 16px; font-size: 12px; font-weight: 500; }
.article h2 { font-size: 1.5rem; margin: 32px 0 16px; padding-top: 24px; border-top: 1px solid #f1f5f9; }
.article h3 { font-size: 1.2rem; margin: 24px 0 12px; }
.article p { margin-bottom: 16px; color: #374151; }
.article blockquote { margin: 24px 0; padding: 16px 24px; border-left: 4px solid #6366f1; background: #f8fafc; font-style: italic; color: #475569; border-radius: 0 8px 8px 0; }
.article pre { background: #1e293b; color: #e2e8f0; padding: 16px 20px; border-radius: 8px; overflow-x: auto; margin: 16px 0; font-size: 14px; line-height: 1.5; }
.article code { font-family: 'SF Mono', 'Fira Code', monospace; }
.article img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
.figure { margin: 24px 0; text-align: center; }
.figure figcaption { font-size: 13px; color: #64748b; margin-top: 8px; }
.comments { margin-top: 40px; }
.comments h2 { border-top: 2px solid #e2e8f0; }
.comment { padding: 16px; margin: 12px 0; border: 1px solid #e2e8f0; border-radius: 8px; transition: box-shadow 0.2s; }
.comment:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.comment-author { font-weight: 600; font-size: 14px; }
.comment-date { font-size: 12px; color: #94a3b8; }
.comment-body { font-size: 14px; color: #475569; }
.comment-actions { display: flex; gap: 12px; margin-top: 8px; }
.comment-actions button { background: none; border: none; color: #6366f1; font-size: 13px; cursor: pointer; padding: 2px 0; }
.sidebar { position: sticky; top: 72px; align-self: start; }
.sidebar section { margin-bottom: 32px; }
.sidebar h3 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 12px; }
.sidebar ul { list-style: none; }
.sidebar li { margin-bottom: 8px; }
.sidebar a { color: #3730a3; text-decoration: none; font-size: 14px; }
.sidebar a:hover { text-decoration: underline; }
.toc a { display: block; padding: 4px 0; border-left: 2px solid transparent; padding-left: 12px; }
.toc a:hover { border-left-color: #6366f1; }
.sidebar .widget { background: #f8fafc; padding: 16px; border-radius: 8px; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } }
</style>
</head>
<body>
<nav class="nav">
<strong style="font-size:18px;color:#fff">TechBlog</strong>
<a href="#">Home</a><a href="#">Articles</a><a href="#">Tutorials</a><a href="#">About</a>
<a href="#">Open Source</a><a href="#">Newsletter</a><a href="#">Contact</a>
<div style="flex:1"></div>
<a href="#">Sign In</a>
</nav>
<div class="layout">
<main class="article">
<h1>Understanding Modern Browser Engine Architecture</h1>
<div class="meta">
<span>By <strong>Dr. Alexandra Chen</strong></span>
<span>March 15, 2025</span>
<span>18 min read</span>
<span>2,847 views</span>
</div>
<div class="tags">
<span class="tag">Browser Engines</span><span class="tag">Performance</span>
<span class="tag">Web Standards</span><span class="tag">Rendering</span>
<span class="tag">Architecture</span><span class="tag">Open Source</span>
</div>
<p>Modern browser engines are among the most complex pieces of software ever created. They must parse HTML, CSS, and JavaScript, construct a DOM tree, compute styles, perform layout calculations, paint pixels, and composite layers -- all within milliseconds to maintain 60fps rendering.</p>
<p>This article explores the architecture of modern browser engines, examining how they process web content from raw bytes to rendered pixels on screen. We will trace the critical rendering path, examine optimization strategies, and understand why certain patterns lead to better performance.</p>
<h2>The Critical Rendering Path</h2>
<p>When a browser receives an HTML document, it begins a multi-stage pipeline known as the critical rendering path. Each stage transforms the document into progressively more structured representations until pixels are painted on screen.</p>
<p>The first stage involves parsing the HTML into a Document Object Model (DOM). The parser processes tokens sequentially, building a tree structure that represents the document's hierarchy. During this phase, the parser may encounter external resources like stylesheets and scripts that can block further processing.</p>
<p>CSS parsing happens in parallel where possible. The browser constructs the CSS Object Model (CSSOM), which represents all the style rules that apply to the document. This includes user-agent styles, author styles, and any inline styles specified directly on elements.</p>
<p>Once both the DOM and CSSOM are available, the browser combines them into a render tree. This tree contains only the elements that will be visible on screen -- elements with <code>display: none</code> are excluded, while pseudo-elements like <code>::before</code> and <code>::after</code> are added.</p>
<p>Layout (also called reflow) is the process of calculating the exact position and size of each element in the render tree. This is one of the most computationally expensive operations in the rendering pipeline, as changes to one element can cascade through the entire tree.</p>
<blockquote>"The fastest code is code that doesn't run. The fastest layout is layout that doesn't need to happen." -- Chrome DevTools Team</blockquote>
<h2>DOM Construction and Tree Building</h2>
<p>The DOM is a tree-structured representation of the HTML document. Each node in the tree corresponds to an element, text node, comment, or other construct in the HTML. The tree preserves the hierarchical relationships between elements, allowing efficient traversal and manipulation.</p>
<p>Modern parsers handle malformed HTML gracefully through error recovery algorithms specified in the HTML5 standard. This includes automatic closing of unclosed tags, adoption of misplaced elements, and reconstruction of the formatting element list.</p>
<p>Shadow DOM introduces additional complexity by creating encapsulated subtrees that can have their own scoped styles and behavior. Custom elements use shadow roots to attach shadow trees, which are rendered in place of the element's regular children.</p>
<h3>Incremental DOM Updates</h3>
<p>When JavaScript modifies the DOM, the browser must determine which parts of the rendering pipeline need to be re-executed. Modern engines use fine-grained invalidation to minimize the work required. A change to an element's text content, for example, may only require a repaint, while changing its width could trigger a full relayout of its subtree.</p>
<p>Mutation observers provide a way for JavaScript to respond to DOM changes without polling. The browser batches mutations and delivers them asynchronously, allowing multiple changes to be processed efficiently in a single callback.</p>
<h3>Memory Management</h3>
<p>DOM nodes are reference-counted objects that are garbage collected when no longer reachable. However, detached DOM trees -- subtrees that have been removed from the document but are still referenced by JavaScript -- represent a common source of memory leaks in web applications.</p>
<p>Browser engines use various strategies to minimize memory overhead: string interning for attribute names and common values, node pools for rapid allocation, and lazy initialization of rarely-accessed properties.</p>
<h2>Style Resolution and Cascade</h2>
<p>CSS style resolution involves matching each element against all applicable style rules and computing the final value for every CSS property. With thousands of rules and millions of elements on complex pages, this process must be highly optimized.</p>
<p>Modern engines use Bloom filters to quickly eliminate rules that cannot match an element, reducing the number of full selector matches required. Selector matching proceeds right-to-left, starting from the key selector (the rightmost part) and working backwards through ancestors.</p>
<p>The cascade algorithm resolves conflicts between competing declarations by considering origin, specificity, and source order. Custom properties (CSS variables) add another layer of complexity, as they must be resolved during the cascade before they can be used in property values.</p>
<p>Style sharing is an optimization where elements with identical computed styles share a single style data structure rather than each maintaining their own copy. This is particularly effective on pages with repetitive structures like lists and tables.</p>
<pre><code>/* Example: These list items can share computed styles */
.data-grid tr:nth-child(even) td {
background-color: #f8fafc;
padding: 8px 12px;
font-size: 14px;
border-bottom: 1px solid #e2e8f0;
}</code></pre>
<h2>Layout Algorithms</h2>
<p>Layout is the process of converting the styled render tree into a set of positioned boxes with concrete pixel dimensions. Different layout modes (block, inline, flex, grid, table) each have their own algorithm for determining element sizes and positions.</p>
<p>Flexbox layout involves multiple passes: first computing the flex basis of each item, then distributing free space according to flex-grow and flex-shrink factors, and finally positioning items along the cross axis. This multi-pass nature makes flex layout more expensive than simple block layout.</p>
<p>Grid layout is even more complex, supporting both explicit and implicit grid definitions, named areas, auto-placement, and spanning. The grid placement algorithm must resolve conflicts between explicitly-placed and auto-placed items while respecting sizing constraints.</p>
<p>Containing block queries are a frequent operation during layout. An element's containing block determines its available width for percentage calculations and establishes the coordinate system for positioned descendants. Finding the correct containing block requires walking up the tree, checking for elements that establish new containing blocks.</p>
<p>Fragmentation handles content that must be split across multiple pages or columns. The fragmentation algorithm inserts breaks at legal break points, avoiding orphans and widows while respecting the <code>break-before</code>, <code>break-after</code>, and <code>break-inside</code> properties.</p>
<h2>Paint and Compositing</h2>
<p>After layout, the browser must paint the visual representation of each element. This involves drawing backgrounds, borders, text, images, shadows, and other visual effects in the correct stacking order defined by the z-index property and stacking context rules.</p>
<p>Modern browsers use a layered compositing architecture. Elements that change frequently (animations, scrolling regions, video) are promoted to their own compositing layers. These layers can be updated independently and composited together on the GPU, avoiding expensive repaints of the entire page.</p>
<p>The compositor thread operates independently from the main thread, allowing smooth scrolling and animations even when JavaScript is executing. Touch events and scroll gestures are handled directly by the compositor, with the main thread notified asynchronously.</p>
<p>Paint operations are recorded into display lists -- serialized sequences of drawing commands. These display lists can be rasterized by worker threads on the CPU or directly by the GPU, depending on the content and the platform's capabilities.</p>
<p>Subpixel antialiasing, font hinting, and text shaping add complexity to text rendering. Each glyph must be positioned with fractional pixel precision, and the rendering must account for kerning pairs, ligatures, and complex scripts like Arabic and Devanagari that require contextual glyph substitution.</p>
<h2>JavaScript Engine Integration</h2>
<p>The JavaScript engine is tightly integrated with the browser's rendering pipeline. Script execution can trigger style recalculation, layout, and paint through DOM manipulation and CSSOM access. The browser must balance responsive script execution with maintaining smooth rendering.</p>
<p>Modern engines use just-in-time (JIT) compilation to achieve near-native performance for hot code paths. The compilation pipeline typically includes an interpreter for initial execution, a baseline compiler for warm functions, and an optimizing compiler for hot functions. Deoptimization handles cases where optimistic assumptions are invalidated.</p>
<p>Web Workers provide true parallelism by running JavaScript in separate threads with their own heap and message-passing communication. SharedArrayBuffer enables shared memory between workers, but requires careful synchronization to avoid data races.</p>
<p>The event loop orchestrates the interleaving of script execution, rendering, and I/O callbacks. Microtasks (promises, mutation observers) are processed between macrotasks, and rendering updates are synchronized with the display's refresh rate through requestAnimationFrame.</p>
<h2>Conclusion</h2>
<p>Browser engines represent decades of engineering effort to make the web fast, secure, and compatible. Understanding their architecture helps web developers write code that works with the browser rather than against it, leading to better performance and user experience.</p>
<p>As the web platform continues to evolve with new APIs, layout modes, and rendering capabilities, browser engines must adapt while maintaining backwards compatibility with billions of existing web pages. This tension between innovation and compatibility remains one of the greatest challenges in software engineering.</p>
<div class="comments">
<h2>Comments (50)</h2>
<script>
(function() {
var container = document.querySelector('.comments');
var names = ['Alex Morgan', 'Jamie Rivera', 'Sam Patel', 'Taylor Kim', 'Jordan Lee',
'Casey Wu', 'Riley Chen', 'Morgan Davis', 'Avery Singh', 'Quinn Zhao'];
for (var i = 0; i < 50; i++) {
var div = document.createElement('div');
div.className = 'comment';
div.dataset.id = i;
var d = new Date(2025, 2, 15 - Math.floor(i / 5));
div.innerHTML = '<div class="comment-header"><span class="comment-author">' + names[i % 10] +
'</span><span class="comment-date">' + d.toLocaleDateString() + '</span></div>' +
'<div class="comment-body"><p>' + (i % 3 === 0 ?
'Great article! The section on compositing layers was particularly insightful. I have been struggling with janky scroll performance and this explains why promoting elements to their own layer helps.' :
i % 3 === 1 ?
'Thanks for the detailed breakdown. One thing I would add is that the style invalidation strategy varies significantly between engines. Blink uses a different approach from WebKit for descendant invalidation.' :
'This is exactly the kind of deep dive I was looking for. The paint and compositing section cleared up several misconceptions I had about how GPU acceleration works in practice.') +
'</p></div><div class="comment-actions"><button>Reply</button><button>Like (' + (Math.floor(Math.random() * 30)) + ')</button></div>';
container.appendChild(div);
}
})();
</script>
</div>
</main>
<aside class="sidebar">
<section>
<h3>Table of Contents</h3>
<ul class="toc">
<li><a href="#crp">The Critical Rendering Path</a></li>
<li><a href="#dom">DOM Construction and Tree Building</a></li>
<li><a href="#styles">Style Resolution and Cascade</a></li>
<li><a href="#layout">Layout Algorithms</a></li>
<li><a href="#paint">Paint and Compositing</a></li>
<li><a href="#js">JavaScript Engine Integration</a></li>
<li><a href="#conclusion">Conclusion</a></li>
</ul>
</section>
<section>
<h3>Related Articles</h3>
<ul>
<li><a href="#">How V8 Optimizes JavaScript Execution</a></li>
<li><a href="#">CSS Grid Layout: A Complete Guide</a></li>
<li><a href="#">Web Performance Metrics That Matter</a></li>
<li><a href="#">Understanding the Event Loop</a></li>
<li><a href="#">Debugging Layout Thrashing</a></li>
<li><a href="#">Service Workers and Caching Strategies</a></li>
<li><a href="#">WebAssembly: Beyond JavaScript</a></li>
<li><a href="#">Rendering Performance Case Studies</a></li>
<li><a href="#">Accessibility Tree Deep Dive</a></li>
<li><a href="#">Cross-Browser Compatibility Patterns</a></li>
<li><a href="#">Progressive Web Apps in 2025</a></li>
<li><a href="#">HTTP/3 and QUIC Explained</a></li>
<li><a href="#">Container Queries Guide</a></li>
<li><a href="#">CSS Houdini: Low-Level APIs</a></li>
<li><a href="#">Browser DevTools Advanced Tips</a></li>
</ul>
</section>
<section>
<h3>Archives</h3>
<ul>
<li><a href="#">March 2025</a></li><li><a href="#">February 2025</a></li>
<li><a href="#">January 2025</a></li><li><a href="#">December 2024</a></li>
<li><a href="#">November 2024</a></li><li><a href="#">October 2024</a></li>
<li><a href="#">September 2024</a></li><li><a href="#">August 2024</a></li>
<li><a href="#">July 2024</a></li><li><a href="#">June 2024</a></li>
<li><a href="#">May 2024</a></li><li><a href="#">April 2024</a></li>
</ul>
</section>
<section class="widget">
<h3>Newsletter</h3>
<p style="font-size:13px;color:#475569;margin-bottom:8px">Get weekly browser engineering insights</p>
<input type="email" placeholder="you@example.com" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;margin-bottom:8px">
<button style="width:100%;padding:8px;background:#6366f1;color:#fff;border:none;border-radius:4px;cursor:pointer">Subscribe</button>
</section>
</aside>
</div>
</body>
</html>
-248
View File
@@ -1,248 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Operations Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f1f5f9; color: #0f172a; }
.header { background: #0f172a; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 18px; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.header input { padding: 6px 12px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 6px; font-size: 13px; width: 200px; }
.header button { padding: 6px 16px; background: #6366f1; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; padding: 24px; }
.card { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card-label { font-size: 13px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.card-value { font-size: 32px; font-weight: 700; margin: 8px 0 4px; }
.card-trend { font-size: 14px; font-weight: 500; }
.card-trend.up { color: #16a34a; }
.card-trend.down { color: #dc2626; }
.card-sparkline { height: 40px; display: flex; align-items: flex-end; gap: 2px; margin-top: 8px; }
.card-sparkline .bar { flex: 1; background: #e0e7ff; border-radius: 2px; min-width: 3px; transition: background 0.2s; }
.card-sparkline .bar:last-child { background: #6366f1; }
.section { margin: 0 24px 24px; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
.section-header { padding: 16px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; }
.section-header h2 { font-size: 16px; }
.tabs { display: flex; gap: 0; }
.tab { padding: 6px 16px; font-size: 13px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; }
.tab:first-child { border-radius: 6px 0 0 6px; }
.tab:last-child { border-radius: 0 6px 6px 0; }
.tab.active { background: #6366f1; color: #fff; border-color: #6366f1; }
.chart { padding: 20px; height: 240px; display: flex; align-items: flex-end; gap: 4px; }
.chart .bar { flex: 1; background: #6366f1; border-radius: 4px 4px 0 0; position: relative; min-width: 6px; transition: opacity 0.2s; }
.chart .bar:hover { opacity: 0.8; }
.chart .bar-label { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: #94a3b8; white-space: nowrap; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: #f8fafc; padding: 10px 16px; text-align: left; font-weight: 600; color: #475569; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; }
tbody td { padding: 8px 16px; border-bottom: 1px solid #f1f5f9; }
tbody tr:hover { background: #f8fafc; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge-info { background: #dbeafe; color: #1d4ed8; }
.badge-warn { background: #fef3c7; color: #b45309; }
.badge-error { background: #fee2e2; color: #dc2626; }
.badge-debug { background: #f1f5f9; color: #475569; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-inactive { background: #f1f5f9; color: #64748b; }
.badge-pending { background: #fef3c7; color: #b45309; }
.pagination { display: flex; justify-content: center; gap: 4px; padding: 16px; }
.pagination button { width: 32px; height: 32px; border: 1px solid #e2e8f0; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
.pagination button.active { background: #6366f1; color: #fff; border-color: #6366f1; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 0 24px 24px; }
@media (max-width: 1024px) { .grid { grid-template-columns: repeat(2, 1fr); } .two-col { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>Operations Dashboard</h1>
<div class="header-actions">
<input id="search" type="text" placeholder="Search...">
<button id="refresh">Refresh</button>
<button style="background:#334155">Export</button>
</div>
</div>
<div class="grid" id="metrics-grid"></div>
<div class="section">
<div class="section-header">
<h2>Request Volume</h2>
<div class="tabs">
<div class="tab active">Hourly</div>
<div class="tab">Daily</div>
<div class="tab">Weekly</div>
</div>
</div>
<div class="chart" id="chart"></div>
</div>
<div class="two-col">
<div class="section" style="margin:0">
<div class="section-header"><h2>Top Endpoints</h2></div>
<table>
<thead><tr><th>Endpoint</th><th>Requests</th><th>Avg Latency</th><th>Error Rate</th></tr></thead>
<tbody id="endpoints-table"></tbody>
</table>
</div>
<div class="section" style="margin:0">
<div class="section-header"><h2>Active Alerts</h2></div>
<table>
<thead><tr><th>Alert</th><th>Severity</th><th>Service</th><th>Since</th></tr></thead>
<tbody id="alerts-table"></tbody>
</table>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>Recent Logs</h2>
<div class="tabs">
<div class="tab active">All</div>
<div class="tab">Errors</div>
<div class="tab">Warnings</div>
</div>
</div>
<table>
<thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th><th>Duration</th></tr></thead>
<tbody id="logs-table"></tbody>
</table>
<div class="pagination" id="pagination"></div>
</div>
<div class="section">
<div class="section-header"><h2>Service Status</h2></div>
<table>
<thead><tr><th>Service</th><th>Status</th><th>Uptime</th><th>CPU</th><th>Memory</th><th>Requests/min</th><th>Error Rate</th><th>Last Deploy</th></tr></thead>
<tbody id="services-table"></tbody>
</table>
</div>
<script>
(function() {
// Metric cards
var metrics = [
{ label: 'Total Requests', value: '1,284,392', trend: '+12.5%', up: true },
{ label: 'Avg Response Time', value: '142ms', trend: '-8.3%', up: true },
{ label: 'Error Rate', value: '0.42%', trend: '+0.12%', up: false },
{ label: 'Active Users', value: '3,847', trend: '+5.1%', up: true },
{ label: 'Throughput', value: '892/s', trend: '+3.7%', up: true },
{ label: 'P99 Latency', value: '487ms', trend: '+15ms', up: false },
{ label: 'CPU Usage', value: '67%', trend: '-2.4%', up: true },
{ label: 'Memory Usage', value: '4.2GB', trend: '+180MB', up: false },
{ label: 'Cache Hit Rate', value: '94.7%', trend: '+1.2%', up: true },
{ label: 'Queue Depth', value: '234', trend: '-45', up: true },
{ label: 'Open Connections', value: '12,483', trend: '+892', up: false },
{ label: 'Deployments Today', value: '7', trend: '+2', up: true },
];
var grid = document.getElementById('metrics-grid');
metrics.forEach(function(m) {
var sparkBars = '';
for (var s = 0; s < 12; s++) {
var h = 20 + Math.floor(Math.random() * 80);
sparkBars += '<div class="bar" style="height:' + h + '%"></div>';
}
grid.innerHTML += '<div class="card"><div class="card-label">' + m.label +
'</div><div class="card-value">' + m.value +
'</div><div class="card-trend ' + (m.up ? 'up' : 'down') + '">' +
(m.up ? '+' : '') + m.trend +
'</div><div class="card-sparkline">' + sparkBars + '</div></div>';
});
// Chart bars
var chart = document.getElementById('chart');
for (var h = 0; h < 24; h++) {
var height = 15 + ((h * 17 + 7) % 85);
var bar = document.createElement('div');
bar.className = 'bar';
bar.style.height = height + '%';
bar.innerHTML = '<span class="bar-label">' + String(h).padStart(2, '0') + ':00</span>';
chart.appendChild(bar);
}
// Endpoints table
var endpoints = document.getElementById('endpoints-table');
var paths = ['/api/users', '/api/auth', '/api/products', '/api/orders', '/api/search',
'/api/analytics', '/api/notifications', '/api/payments', '/api/inventory', '/api/reports',
'/api/settings', '/api/uploads', '/api/webhooks', '/api/health', '/api/metrics'];
paths.forEach(function(p, i) {
endpoints.innerHTML += '<tr><td><code>' + p + '</code></td><td>' +
(50000 - i * 3000) + '</td><td>' + (45 + i * 12) + 'ms</td><td>' +
(0.1 + i * 0.08).toFixed(2) + '%</td></tr>';
});
// Alerts table
var alerts = document.getElementById('alerts-table');
var alertData = [
['High error rate on /api/payments', 'error', 'payments'],
['Memory usage above 85%', 'warn', 'api-gateway'],
['Slow queries detected', 'warn', 'database'],
['SSL certificate expiring in 7 days', 'info', 'infrastructure'],
['Disk usage above 80%', 'warn', 'storage'],
['Connection pool exhaustion', 'error', 'database'],
['Rate limit threshold reached', 'warn', 'api-gateway'],
['Deployment rollback detected', 'info', 'ci-cd'],
];
alertData.forEach(function(a, i) {
var badge = a[1] === 'error' ? 'badge-error' : a[1] === 'warn' ? 'badge-warn' : 'badge-info';
alerts.innerHTML += '<tr><td>' + a[0] + '</td><td><span class="badge ' + badge + '">' +
a[1].toUpperCase() + '</span></td><td>' + a[2] + '</td><td>' + (i * 15 + 5) + 'm ago</td></tr>';
});
// Logs table
var logs = document.getElementById('logs-table');
var services = ['auth', 'api-gateway', 'worker', 'cache', 'database', 'payments', 'search', 'notifications'];
var levels = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
var messages = [
'Request processed successfully',
'Connection pool running low',
'Failed to connect to upstream service',
'Cache miss for key session:',
'Query execution exceeded threshold',
'Payment webhook received',
'Search index rebuild started',
'Rate limit applied to client',
'Health check passed',
'Background job completed',
];
for (var i = 0; i < 200; i++) {
var level = levels[i % 4];
var badge = level === 'ERROR' ? 'badge-error' : level === 'WARN' ? 'badge-warn' :
level === 'DEBUG' ? 'badge-debug' : 'badge-info';
var ts = new Date(2025, 2, 15, 23 - Math.floor(i / 8), 59 - (i % 60));
logs.innerHTML += '<tr><td style="white-space:nowrap">' + ts.toISOString().replace('T', ' ').substring(0, 19) +
'</td><td><span class="badge ' + badge + '">' + level +
'</span></td><td>' + services[i % 8] +
'</td><td>' + messages[i % 10] + ' #' + (i + 1) +
'</td><td>' + (Math.floor(Math.random() * 500) + 10) + 'ms</td></tr>';
}
// Pagination
var pag = document.getElementById('pagination');
for (var p = 1; p <= 10; p++) {
pag.innerHTML += '<button class="' + (p === 1 ? 'active' : '') + '">' + p + '</button>';
}
// Services table
var svcTable = document.getElementById('services-table');
var svcNames = ['api-gateway', 'auth-service', 'payment-processor', 'search-engine',
'notification-hub', 'analytics-pipeline', 'cache-layer', 'worker-pool',
'storage-service', 'cdn-origin', 'queue-processor', 'ml-inference'];
svcNames.forEach(function(name, i) {
var status = i < 9 ? 'active' : i === 9 ? 'pending' : 'inactive';
var badge = status === 'active' ? 'badge-active' : status === 'pending' ? 'badge-pending' : 'badge-inactive';
svcTable.innerHTML += '<tr><td><strong>' + name + '</strong></td>' +
'<td><span class="badge ' + badge + '">' + status + '</span></td>' +
'<td>' + (99.9 - i * 0.05).toFixed(2) + '%</td>' +
'<td>' + (30 + i * 5) + '%</td>' +
'<td>' + (512 + i * 128) + 'MB</td>' +
'<td>' + (2000 - i * 150) + '</td>' +
'<td>' + (0.1 + i * 0.04).toFixed(2) + '%</td>' +
'<td>' + (i + 1) + 'h ago</td></tr>';
});
})();
</script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TechStore - Electronics & Gadgets</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #fff; color: #0f172a; }
.topbar { background: #0f172a; color: #94a3b8; font-size: 12px; padding: 6px 24px; display: flex; justify-content: space-between; }
.navbar { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; background: #fff; z-index: 100; }
.navbar .logo { font-size: 22px; font-weight: 800; color: #6366f1; }
.navbar .search { flex: 1; max-width: 500px; position: relative; }
.navbar .search input { width: 100%; padding: 10px 16px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 14px; }
.navbar .search input:focus { border-color: #6366f1; outline: none; }
.nav-links { display: flex; gap: 20px; }
.nav-links a { text-decoration: none; color: #475569; font-size: 14px; }
.nav-actions { display: flex; gap: 16px; align-items: center; }
.nav-actions button { background: none; border: none; font-size: 14px; cursor: pointer; color: #475569; }
.cart-badge { background: #6366f1; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; margin-left: 4px; }
.categories { display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
.categories a { padding: 10px 16px; font-size: 13px; color: #64748b; text-decoration: none; white-space: nowrap; border-bottom: 2px solid transparent; }
.categories a:hover, .categories a.active { color: #6366f1; border-bottom-color: #6366f1; }
.hero { background: linear-gradient(135deg, #312e81, #6366f1); color: #fff; padding: 60px 24px; text-align: center; }
.hero h2 { font-size: 2.5rem; margin-bottom: 12px; }
.hero p { font-size: 18px; opacity: 0.9; margin-bottom: 24px; }
.hero button { padding: 12px 32px; background: #fff; color: #6366f1; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
.container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
.section-title { font-size: 1.5rem; font-weight: 700; margin: 32px 0 16px; display: flex; justify-content: space-between; align-items: center; }
.section-title a { font-size: 14px; color: #6366f1; text-decoration: none; }
.product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 32px; }
.product { border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: box-shadow 0.2s, transform 0.2s; }
.product:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-2px); }
.product-img { height: 200px; display: flex; align-items: center; justify-content: center; font-size: 48px; }
.product-info { padding: 16px; }
.product-brand { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.product-name { font-size: 15px; font-weight: 600; margin: 4px 0 8px; line-height: 1.3; }
.product-price { font-size: 20px; font-weight: 700; color: #0f172a; }
.product-original { font-size: 14px; color: #94a3b8; text-decoration: line-through; margin-left: 8px; }
.product-rating { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 13px; color: #64748b; }
.stars { color: #f59e0b; }
.product-actions { display: flex; gap: 8px; margin-top: 12px; }
.product-actions button { flex: 1; padding: 8px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
.btn-primary { background: #6366f1; color: #fff; }
.btn-secondary { background: #f1f5f9; color: #475569; }
.filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.filter { padding: 6px 16px; border: 1px solid #e2e8f0; border-radius: 20px; font-size: 13px; background: #fff; cursor: pointer; }
.filter.active { background: #6366f1; color: #fff; border-color: #6366f1; }
.deals-banner { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 12px; padding: 20px 24px; margin: 24px 0; display: flex; justify-content: space-between; align-items: center; }
.deals-banner h3 { color: #b45309; }
.reviews { margin: 24px 0; }
.review { padding: 16px; border-bottom: 1px solid #f1f5f9; }
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.review-author { font-weight: 600; font-size: 14px; }
.review-date { font-size: 12px; color: #94a3b8; }
.review-body { font-size: 14px; color: #475569; }
.footer { background: #0f172a; color: #94a3b8; padding: 48px 24px; margin-top: 48px; }
.footer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; max-width: 1280px; margin: 0 auto; }
.footer h4 { color: #fff; margin-bottom: 16px; font-size: 14px; }
.footer a { display: block; color: #94a3b8; text-decoration: none; font-size: 13px; margin-bottom: 8px; }
.footer-bottom { border-top: 1px solid #1e293b; padding-top: 24px; margin-top: 32px; text-align: center; font-size: 13px; max-width: 1280px; margin-left: auto; margin-right: auto; }
</style>
</head>
<body>
<div class="topbar">
<span>Free shipping on orders over $99</span>
<span>Customer Service: 1-800-TECH | Track Order | Help</span>
</div>
<nav class="navbar">
<div class="logo">TechStore</div>
<div class="search"><input type="text" placeholder="Search products, brands, categories..."></div>
<div class="nav-actions">
<button>Account</button>
<button>Wishlist</button>
<button>Cart <span class="cart-badge">3</span></button>
</div>
</nav>
<div class="categories">
<a href="#" class="active">All</a><a href="#">Laptops</a><a href="#">Phones</a>
<a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a>
<a href="#">Monitors</a><a href="#">Storage</a><a href="#">Networking</a>
<a href="#">Accessories</a><a href="#">Deals</a><a href="#">New Arrivals</a>
</div>
<div class="hero">
<h2>Spring Tech Sale</h2>
<p>Up to 40% off on selected electronics. Limited time offer.</p>
<button>Shop Now</button>
</div>
<div class="container">
<div class="deals-banner">
<div><h3>Flash Deals - Ends in 04:32:17</h3><p style="font-size:13px;color:#92400e">Extra 15% off with code SPRING15</p></div>
<button class="btn-primary" style="padding:10px 24px;border-radius:8px;border:none;cursor:pointer">View All Deals</button>
</div>
<div class="section-title"><span>Featured Products</span><a href="#">View All</a></div>
<div class="filters">
<span class="filter active">All</span><span class="filter">Under $100</span>
<span class="filter">$100 - $500</span><span class="filter">$500+</span>
<span class="filter">Top Rated</span><span class="filter">New</span>
</div>
<div class="product-grid" id="featured-grid"></div>
<div class="section-title"><span>Best Sellers</span><a href="#">View All</a></div>
<div class="product-grid" id="bestsellers-grid"></div>
<div class="section-title"><span>New Arrivals</span><a href="#">View All</a></div>
<div class="product-grid" id="newarrivals-grid"></div>
<div class="section-title"><span>Customer Reviews</span></div>
<div class="reviews" id="reviews"></div>
</div>
<footer class="footer">
<div class="footer-grid">
<div><h4>Shop</h4><a href="#">Laptops</a><a href="#">Phones</a><a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a><a href="#">Monitors</a><a href="#">Accessories</a></div>
<div><h4>Support</h4><a href="#">Help Center</a><a href="#">Returns</a><a href="#">Warranty</a><a href="#">Contact Us</a><a href="#">Track Order</a><a href="#">Shipping Info</a></div>
<div><h4>Company</h4><a href="#">About Us</a><a href="#">Careers</a><a href="#">Press</a><a href="#">Blog</a><a href="#">Sustainability</a><a href="#">Investor Relations</a></div>
<div><h4>Connect</h4><a href="#">Newsletter</a><a href="#">Social Media</a><a href="#">Affiliate Program</a><a href="#">Partner With Us</a><a href="#">Developer API</a></div>
</div>
<div class="footer-bottom">2025 TechStore Inc. All rights reserved. | Privacy Policy | Terms of Service | Cookie Settings</div>
</footer>
<script>
(function() {
var brands = ['Apple', 'Samsung', 'Sony', 'Bose', 'Dell', 'Lenovo', 'LG', 'ASUS', 'Logitech', 'Canon', 'Nikon', 'JBL'];
var categories = ['Laptop', 'Phone', 'Tablet', 'Headphones', 'Camera', 'Monitor', 'Speaker', 'Keyboard'];
var colors = ['#dbeafe', '#fce7f3', '#d1fae5', '#fef3c7', '#e0e7ff', '#f1f5f9', '#fef2f2', '#f0fdf4'];
function makeProduct(i) {
var brand = brands[i % brands.length];
var cat = categories[i % categories.length];
var price = 49 + (i * 73) % 1500;
var original = Math.round(price * 1.25);
var rating = (3.5 + (i % 15) * 0.1).toFixed(1);
var reviews = 50 + (i * 37) % 2000;
var stars = '';
for (var s = 0; s < 5; s++) stars += s < Math.round(parseFloat(rating)) ? '*' : ' ';
return '<div class="product"><div class="product-img" style="background:' + colors[i % 8] + '">' +
cat.charAt(0).toUpperCase() + '</div><div class="product-info">' +
'<div class="product-brand">' + brand + '</div>' +
'<div class="product-name">' + brand + ' ' + cat + ' Pro ' + (2024 + (i % 3)) + ' Edition</div>' +
'<div><span class="product-price">$' + price + '</span><span class="product-original">$' + original + '</span></div>' +
'<div class="product-rating"><span class="stars">' + stars + '</span> ' + rating + ' (' + reviews + ')</div>' +
'<div class="product-actions"><button class="btn-primary">Add to Cart</button><button class="btn-secondary">Compare</button></div>' +
'</div></div>';
}
var featured = document.getElementById('featured-grid');
for (var i = 0; i < 16; i++) featured.innerHTML += makeProduct(i);
var bestsellers = document.getElementById('bestsellers-grid');
for (var i = 16; i < 32; i++) bestsellers.innerHTML += makeProduct(i);
var newarrivals = document.getElementById('newarrivals-grid');
for (var i = 32; i < 48; i++) newarrivals.innerHTML += makeProduct(i);
var reviewsEl = document.getElementById('reviews');
var names = ['Alice M.', 'Bob K.', 'Carol S.', 'David L.', 'Eva R.', 'Frank W.', 'Grace H.', 'Henry P.'];
var reviewTexts = [
'Excellent product, arrived faster than expected. Build quality is outstanding.',
'Good value for money. Would recommend to anyone looking for a reliable device.',
'Decent product but the battery life could be better. Works well otherwise.',
'Amazing quality! This is my third purchase from this brand and they never disappoint.',
];
for (var i = 0; i < 20; i++) {
reviewsEl.innerHTML += '<div class="review"><div class="review-header"><div><span class="review-author">' +
names[i % 8] + '</span></div><span class="review-date">March ' + (15 - i % 15) + ', 2025</span></div>' +
'<div class="review-body">' + reviewTexts[i % 4] + '</div></div>';
}
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-119
View File
@@ -1,119 +0,0 @@
export interface BenchmarkCommand {
id: string;
action: string;
[key: string]: unknown;
}
export interface Scenario {
name: string;
description: string;
/** Commands to run once before measured iterations (e.g. navigate to a page). */
setup?: BenchmarkCommand[];
/** The commands whose total execution time is measured per iteration. */
commands: BenchmarkCommand[];
/** Commands to run once after measured iterations (e.g. cleanup). */
teardown?: BenchmarkCommand[];
}
const FORM_HTML = [
"<html><head><title>Bench</title></head><body>",
"<h1>Benchmark Page</h1>",
"<input id='name' type='text' placeholder='Name'>",
"<input id='email' type='email' placeholder='Email'>",
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
"<input id='agree' type='checkbox'>",
"<textarea id='bio' placeholder='Bio'></textarea>",
"<button id='submit'>Submit</button>",
"<p id='status'>Ready</p>",
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
"<ul>",
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
"</ul>",
"</body></html>",
].join("");
const INJECT_FORM: BenchmarkCommand = {
id: "inject",
action: "evaluate",
script: `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`,
};
const SETUP_PAGE: BenchmarkCommand[] = [
{ id: "setup-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
INJECT_FORM,
];
export const scenarios: Scenario[] = [
{
name: "navigate",
description: "Page navigation (about:blank round-trip)",
commands: [
{ id: "nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
],
},
{
name: "snapshot",
description: "DOM snapshot (accessibility tree)",
setup: SETUP_PAGE,
commands: [
{ id: "snap", action: "snapshot" },
],
},
{
name: "screenshot",
description: "Screenshot capture",
setup: SETUP_PAGE,
commands: [
{ id: "ss", action: "screenshot" },
],
},
{
name: "evaluate",
description: "JavaScript evaluation",
setup: SETUP_PAGE,
commands: [
{ id: "eval", action: "evaluate", script: "document.title + ' ' + document.querySelectorAll('li').length" },
],
},
{
name: "click",
description: "Element click interaction",
setup: SETUP_PAGE,
commands: [
{ id: "clk", action: "click", selector: "#link" },
],
},
{
name: "fill",
description: "Form field fill",
setup: SETUP_PAGE,
commands: [
{ id: "fill", action: "fill", selector: "#name", value: "Benchmark User" },
],
},
{
name: "tabs",
description: "Tab new + list + switch",
commands: [
{ id: "tnew", action: "tab_new", url: "about:blank" },
{ id: "tlist", action: "tab_list" },
{ id: "tswitch", action: "tab_switch", index: 0 },
],
teardown: [
{ id: "tclose", action: "tab_close", index: 1 },
],
},
{
name: "full-workflow",
description: "Realistic agent workflow: navigate, snapshot, click, fill, evaluate, screenshot",
commands: [
{ id: "w-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
INJECT_FORM,
{ id: "w-snap", action: "snapshot" },
{ id: "w-click", action: "click", selector: "#link" },
{ id: "w-fill", action: "fill", selector: "#name", value: "Agent User" },
{ id: "w-eval", action: "evaluate", script: "document.getElementById('name').value" },
{ id: "w-ss", action: "screenshot" },
],
},
];
-261
View File
@@ -1,261 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk';
import { mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
import path from 'node:path';
const AI_GATEWAY_URL =
process.env.ANTHROPIC_BASE_URL || 'https://ai-gateway.vercel.sh';
const API_KEY = process.env.AI_GATEWAY_API_KEY;
const MODEL = process.env.DOGFOOD_MODEL || 'anthropic/claude-haiku-4.5';
const CUSTOM_URL = process.env.DOGFOOD_URL;
const FIXTURE_PATH = path.resolve('test/e2e/fixtures/buggy-app.html');
const SKILL_PATH = path.resolve('skills/dogfood/SKILL.md');
const TARGET_URL = CUSTOM_URL || `file://${FIXTURE_PATH}`;
const IS_FIXTURE = !CUSTOM_URL;
const OUTPUT_DIR = path.resolve('test/e2e/.dogfood-output');
const EVAL_TIMEOUT = 10 * 60 * 1000;
async function runDogfood(outputDir: string): Promise<{
result: SDKResultMessage | null;
messages: SDKMessage[];
toolsUsed: Set<string>;
}> {
const instruction = [
`Read the dogfood skill at ${SKILL_PATH} and follow its workflow.`,
`Dogfood ${TARGET_URL}`,
`Output directory: ${outputDir}`,
].join(' ');
const messages: SDKMessage[] = [];
const toolsUsed = new Set<string>();
let result: SDKResultMessage | null = null;
const conversation = query({
prompt: instruction,
options: {
model: MODEL,
cwd: process.cwd(),
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
maxTurns: 80,
maxBudgetUsd: 2,
settingSources: ['project'],
persistSession: false,
env: {
...process.env,
ANTHROPIC_BASE_URL: AI_GATEWAY_URL,
ANTHROPIC_API_KEY: API_KEY,
},
},
});
const verbose = process.env.DOGFOOD_VERBOSE !== '0';
const log = verbose ? (msg: string) => process.stderr.write(` [dogfood] ${msg}\n`) : () => {};
const chatLogPath = path.join(outputDir, 'chat-log.jsonl');
writeFileSync(chatLogPath, '');
function appendToLog(entry: Record<string, unknown>) {
appendFileSync(chatLogPath, JSON.stringify(entry) + '\n');
}
for await (const message of conversation) {
messages.push(message);
if (message.type === 'system' && message.subtype === 'init') {
log(`session started (model: ${message.model})`);
appendToLog({ type: 'system', subtype: 'init', model: message.model });
}
if (message.type === 'assistant' && message.message?.content) {
const logParts: Record<string, unknown>[] = [];
for (const block of message.message.content) {
if ('type' in block && block.type === 'tool_use') {
toolsUsed.add(block.name);
const input = block.input as Record<string, unknown>;
let preview: string;
if (block.name === 'Bash') {
const cmd = String(input.command ?? '');
const firstLine = cmd.split('\n').find(l => l.trim() && !l.trim().startsWith('#')) ?? cmd.split('\n')[0];
preview = firstLine.trim().slice(0, 200);
} else if (block.name === 'Write') {
preview = String(input.file_path ?? input.path ?? '');
} else if (block.name === 'Read') {
preview = String(input.file_path ?? input.path ?? '');
} else if (block.name === 'Edit') {
preview = String(input.file_path ?? input.path ?? '');
} else {
preview = JSON.stringify(input).slice(0, 120);
}
log(`${block.name}: ${preview}`);
logParts.push({ tool: block.name, input: block.input });
}
if ('type' in block && block.type === 'text' && block.text) {
const line = block.text.split('\n')[0].slice(0, 120);
if (line.trim()) log(line);
logParts.push({ text: block.text });
}
}
appendToLog({ type: 'assistant', content: logParts });
}
if (message.type === 'result') {
result = message;
const cost = `$${message.total_cost_usd.toFixed(4)}`;
const usage = message.usage;
const cacheRead = usage.cache_read_input_tokens ?? 0;
const cacheCreate = usage.cache_creation_input_tokens ?? 0;
const inputTokens = usage.input_tokens ?? 0;
const cacheInfo = cacheRead > 0
? ` | cache: ${cacheRead} read, ${cacheCreate} created, ${inputTokens} uncached`
: '';
if (message.subtype === 'success') {
log(`done (${message.num_turns} turns, ${cost}${cacheInfo})`);
} else {
log(`stopped: ${message.subtype} (${message.num_turns} turns, ${cost}${cacheInfo})`);
}
appendToLog({ type: 'result', subtype: message.subtype, num_turns: message.num_turns, cost: message.total_cost_usd });
}
}
log(`chat log: ${chatLogPath}`);
return { result, messages, toolsUsed };
}
function findFiles(dir: string, ext: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir, { recursive: true })
.map(String)
.filter((f) => f.endsWith(ext));
}
describe.skipIf(!API_KEY)('Dogfood e2e eval (Agent SDK)', () => {
const outputDir = OUTPUT_DIR;
let evalResult: Awaited<ReturnType<typeof runDogfood>>;
beforeAll(async () => {
if (existsSync(outputDir)) {
rmSync(outputDir, { recursive: true, force: true });
}
mkdirSync(outputDir, { recursive: true });
evalResult = await runDogfood(outputDir);
}, EVAL_TIMEOUT);
it('completes without hard failure', () => {
expect(evalResult.result, 'No result message received').toBeTruthy();
const acceptable = ['success', 'error_max_turns', 'error_max_budget_usd'];
expect(
acceptable,
`Agent failed unexpectedly: ${evalResult.result!.subtype}`
).toContain(evalResult.result!.subtype);
});
it('used agent-browser via Bash tool', () => {
expect(
evalResult.toolsUsed.has('Bash'),
'Agent never used Bash (needed for agent-browser commands)'
).toBe(true);
});
it('produced a report file', () => {
const reportPath = path.join(outputDir, 'report.md');
expect(existsSync(reportPath), 'report.md not found in output dir').toBe(
true
);
});
it('found a minimum number of issues', () => {
const reportPath = path.join(outputDir, 'report.md');
if (!existsSync(reportPath)) return;
const report = readFileSync(reportPath, 'utf-8');
const issueBlocks = report.match(/###\s+ISSUE-\d+/g) || [];
if (IS_FIXTURE) {
expect(
issueBlocks.length,
`Expected >=2 issues from fixture, found ${issueBlocks.length}`
).toBeGreaterThanOrEqual(2);
} else {
expect(issueBlocks.length).toBeGreaterThanOrEqual(1);
}
});
it('each issue has required fields and repro evidence', () => {
const reportPath = path.join(outputDir, 'report.md');
if (!existsSync(reportPath)) return;
const report = readFileSync(reportPath, 'utf-8');
const issueSections = report.split(/(?=###\s+ISSUE-\d+)/).slice(1);
for (const section of issueSections) {
const issueId = section.match(/ISSUE-\d+/)?.[0] ?? 'unknown';
expect(section, `${issueId}: missing Severity`).toMatch(
/\*\*Severity\*\*/i
);
const sevMatch = section.match(
/\*\*Severity\*\*\s*\|?\s*(critical|high|medium|low)/i
);
expect(sevMatch, `${issueId}: invalid severity value`).toBeTruthy();
expect(section, `${issueId}: missing Category`).toMatch(
/\*\*Category\*\*/i
);
expect(section, `${issueId}: missing URL`).toMatch(/\*\*URL\*\*/i);
expect(section, `${issueId}: missing Repro Video field`).toMatch(
/\*\*Repro Video\*\*/i
);
const hasScreenshot = /!\[.*?\]\(.*?\)/.test(section);
const hasReproSteps = /\*\*Repro Steps\*\*/i.test(section);
expect(
hasScreenshot || hasReproSteps,
`${issueId}: needs either screenshot refs or repro steps`
).toBe(true);
}
});
it('has a summary table with non-zero total', () => {
const reportPath = path.join(outputDir, 'report.md');
if (!existsSync(reportPath)) return;
const report = readFileSync(reportPath, 'utf-8');
expect(report, 'Missing Summary section').toContain('## Summary');
const totalMatch = report.match(/\*\*Total\*\*\s*\|?\s*\*\*(\d+)\*\*/);
expect(totalMatch, 'Summary Total not found').toBeTruthy();
if (totalMatch) {
const total = parseInt(totalMatch[1], 10);
expect(total, 'Summary Total should be > 0').toBeGreaterThan(0);
}
});
it('produced screenshot files', () => {
const screenshotsDir = path.join(outputDir, 'screenshots');
const screenshots = findFiles(screenshotsDir, '.png');
expect(
screenshots.length,
'No screenshot files found in output'
).toBeGreaterThan(0);
});
it('produced video files for interactive issues', () => {
const reportPath = path.join(outputDir, 'report.md');
if (!existsSync(reportPath)) return;
const report = readFileSync(reportPath, 'utf-8');
const hasVideoRefs = /videos\/issue-\d+/.test(report);
if (!hasVideoRefs) return;
const videosDir = path.join(outputDir, 'videos');
const videos = findFiles(videosDir, '.webm');
expect(
videos.length,
'Report references videos but none were found'
).toBeGreaterThan(0);
});
});
-210
View File
@@ -1,210 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
const SKILL_DIR = path.resolve('skills/dogfood');
const SKILL_MD = path.join(SKILL_DIR, 'SKILL.md');
const TAXONOMY_MD = path.join(SKILL_DIR, 'references', 'issue-taxonomy.md');
const TEMPLATE_MD = path.join(SKILL_DIR, 'templates', 'dogfood-report-template.md');
function readSkillFile(filePath: string): string {
return readFileSync(filePath, 'utf-8');
}
function parseFrontmatter(content: string): Record<string, string> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
fields[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim();
}
}
return fields;
}
describe('Dogfood skill: file structure', () => {
it('SKILL.md exists', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
it('references/issue-taxonomy.md exists', () => {
expect(existsSync(TAXONOMY_MD)).toBe(true);
});
it('templates/dogfood-report-template.md exists', () => {
expect(existsSync(TEMPLATE_MD)).toBe(true);
});
});
describe('Dogfood skill: SKILL.md frontmatter', () => {
const content = readSkillFile(SKILL_MD);
const frontmatter = parseFrontmatter(content);
it('has name field', () => {
expect(frontmatter.name).toBe('dogfood');
});
it('has description field', () => {
expect(frontmatter.description).toBeTruthy();
expect(frontmatter.description!.length).toBeGreaterThan(50);
});
it('has allowed-tools field', () => {
expect(frontmatter['allowed-tools']).toBeTruthy();
expect(frontmatter['allowed-tools']).toContain('agent-browser');
});
});
describe('Dogfood skill: SKILL.md body references', () => {
const content = readSkillFile(SKILL_MD);
it('references issue-taxonomy.md', () => {
expect(content).toContain('references/issue-taxonomy.md');
});
it('references dogfood-report-template.md', () => {
expect(content).toContain('templates/dogfood-report-template.md');
});
it('referenced files exist on disk', () => {
const refPattern = /\[.*?\]\((references\/.*?\.md|templates\/.*?\.md)\)/g;
const refs = [...content.matchAll(refPattern)].map((m) => m[1]);
expect(refs.length).toBeGreaterThan(0);
for (const ref of refs) {
const fullPath = path.join(SKILL_DIR, ref);
expect(existsSync(fullPath), `Missing: ${ref}`).toBe(true);
}
});
});
describe('Dogfood skill: report template', () => {
const template = readSkillFile(TEMPLATE_MD);
it('has ISSUE- prefix in issue blocks', () => {
expect(template).toContain('ISSUE-');
});
it('has Severity field', () => {
expect(template).toContain('**Severity**');
});
it('has Category field', () => {
expect(template).toContain('**Category**');
});
it('has URL field', () => {
expect(template).toContain('**URL**');
});
it('has Repro Video field', () => {
expect(template).toContain('**Repro Video**');
});
it('has Repro Steps section', () => {
expect(template).toContain('**Repro Steps**');
});
it('has screenshot image references in repro steps', () => {
expect(template).toMatch(/!\[.*?\]\(screenshots\//);
});
it('lists all valid severity values', () => {
expect(template).toMatch(/critical\s*\/\s*high\s*\/\s*medium\s*\/\s*low/);
});
it('lists all valid category values', () => {
const categoryLine = template
.split('\n')
.find((l) => l.includes('**Category**'));
expect(categoryLine).toBeTruthy();
for (const cat of [
'visual',
'functional',
'ux',
'content',
'performance',
'console',
'accessibility',
]) {
expect(categoryLine!.toLowerCase()).toContain(cat);
}
});
it('has Summary table with severity counts', () => {
expect(template).toContain('## Summary');
for (const sev of ['Critical', 'High', 'Medium', 'Low', 'Total']) {
expect(template).toContain(sev);
}
});
});
describe('Dogfood skill: issue taxonomy', () => {
const taxonomy = readSkillFile(TAXONOMY_MD);
it('has severity level definitions', () => {
expect(taxonomy).toContain('## Severity Levels');
for (const sev of ['critical', 'high', 'medium', 'low']) {
expect(taxonomy.toLowerCase()).toContain(`**${sev}**`);
}
});
it('has all 7 category sections', () => {
const expectedCategories = [
'Visual',
'Functional',
'UX',
'Content',
'Performance',
'Console',
'Accessibility',
];
for (const cat of expectedCategories) {
expect(taxonomy).toMatch(new RegExp(`###\\s+.*${cat}`, 'i'));
}
});
it('has exploration checklist', () => {
expect(taxonomy).toContain('## Exploration Checklist');
});
it('checklist has numbered items', () => {
const checklistSection = taxonomy.split('## Exploration Checklist')[1];
expect(checklistSection).toBeTruthy();
const numberedItems = checklistSection!.match(/^\d+\./gm);
expect(numberedItems!.length).toBeGreaterThanOrEqual(5);
});
});
describe('Dogfood skill: cross-consistency', () => {
const template = readSkillFile(TEMPLATE_MD);
const taxonomy = readSkillFile(TAXONOMY_MD);
it('every category in template exists in taxonomy', () => {
const categoryLine = template
.split('\n')
.find((l) => l.includes('**Category**'));
expect(categoryLine).toBeTruthy();
const categories = categoryLine!
.split('|')
.pop()!
.split('/')
.map((c) => c.trim().toLowerCase())
.filter(Boolean);
for (const cat of categories) {
expect(
taxonomy.toLowerCase(),
`Category "${cat}" from template not found in taxonomy`
).toMatch(new RegExp(`###\\s+.*${cat}`));
}
});
it('every severity in template exists in taxonomy', () => {
for (const sev of ['critical', 'high', 'medium', 'low']) {
expect(taxonomy.toLowerCase()).toContain(`**${sev}**`);
}
});
});
-159
View File
@@ -1,159 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Buggy App - Dogfood Test Fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #333; background: #f9f9f9; }
header { background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 20px; }
nav { display: flex; gap: 16px; }
nav a { color: #ccc; text-decoration: none; }
nav a:hover { color: #fff; }
main { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; margin-bottom: 24px; }
.card h2 { margin-bottom: 12px; }
.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
input, textarea { padding: 8px 12px; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; width: 100%; margin-bottom: 12px; }
label { display: block; margin-bottom: 4px; font-weight: 500; }
footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
/* BUG: Visual - clipped text via overflow: hidden on a short container */
.clipped-container {
overflow: hidden;
height: 20px;
border: 1px solid #e0e0e0;
padding: 4px 8px;
margin-top: 8px;
}
/* BUG: Visual - misaligned element */
.misaligned {
display: flex;
align-items: flex-start; /* should be center */
gap: 12px;
padding: 12px;
background: #f0f4ff;
border-radius: 8px;
margin-top: 12px;
}
.misaligned .icon {
width: 40px;
height: 40px;
background: #3b82f6;
border-radius: 50%;
flex-shrink: 0;
margin-top: 14px; /* intentionally off */
}
.misaligned .label {
font-size: 16px;
line-height: 40px;
}
</style>
</head>
<body>
<header>
<h1>Buggy App</h1>
<nav>
<a href="#dashboard">Dashboard</a>
<a href="#settings">Settings</a>
<!-- BUG: Functional - broken link to nonexistent page -->
<a href="#/this-page-does-not-exist">Reports</a>
<a href="#help">Help</a>
</nav>
</header>
<main>
<!-- BUG: Content - typo "Welocme" -->
<h2 style="margin-bottom: 24px;">Welocme to the Dashboard</h2>
<!-- Card 1: Functional bug - button throws JS error -->
<div class="card">
<h2>Quick Actions</h2>
<p>Perform common tasks from here.</p>
<div style="margin-top: 12px; display: flex; gap: 8px;">
<!-- BUG: Functional - button throws JS error on click -->
<button class="btn btn-primary" onclick="processAction()">Run Analysis</button>
<button class="btn btn-danger" onclick="deleteAllData()">Delete All Data</button>
</div>
</div>
<!-- Card 2: Visual bugs - clipped text and misaligned element -->
<div class="card">
<h2>System Status</h2>
<!-- BUG: Visual - text is clipped because container is too short -->
<div class="clipped-container">
The system is currently operating normally. All services are online and responding within expected latency thresholds. Last health check completed at 14:32 UTC.
</div>
<!-- BUG: Visual - icon and label are misaligned -->
<div class="misaligned">
<div class="icon"></div>
<span class="label">All systems operational</span>
</div>
</div>
<!-- Card 3: Content bug - placeholder text -->
<div class="card">
<h2>Recent Activity</h2>
<!-- BUG: Content - lorem ipsum placeholder left in -->
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
</div>
<!-- Card 4: UX bug - form with no feedback on submit -->
<div class="card">
<h2>Contact Support</h2>
<form id="support-form">
<label for="subject">Subject</label>
<input type="text" id="subject" placeholder="Enter subject">
<label for="message">Message</label>
<textarea id="message" rows="3" placeholder="Describe your issue"></textarea>
<!-- BUG: UX - submit does nothing, no feedback -->
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</div>
<!-- Card 5: UX bug - empty state with no message -->
<div class="card">
<h2>Notifications</h2>
<!-- BUG: UX - empty container with no empty state message -->
<div id="notifications-list" style="min-height: 60px;">
</div>
</div>
</main>
<footer>
&copy; 2025 Buggy App Inc. All rights reserved.
</footer>
<script>
// BUG: Console - error on page load
console.error("Failed to initialize analytics: endpoint not configured");
// BUG: Console - failed fetch on page load
fetch("https://api.nonexistent-endpoint.invalid/v1/health")
.catch(function() {});
// BUG: Functional - function referenced by button is broken
function processAction() {
// Throws because undefinedService is not defined
undefinedService.runAnalysis();
}
// No confirmation for destructive action
function deleteAllData() {
alert("All data deleted!");
}
// Form submit does nothing
document.getElementById("support-form").addEventListener("submit", function(e) {
e.preventDefault();
});
</script>
</body>
</html>
-154
View File
@@ -1,154 +0,0 @@
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
import { BrowserManager } from '../src/browser.js';
import { writeFileSync, unlinkSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
describe('File Access (Issue #345)', () => {
let browser: BrowserManager;
const testFilePath = path.join(os.tmpdir(), 'agent-browser-test-file.html');
const testFileUrl = `file://${testFilePath}`;
// Create test HTML file before tests
beforeAll(() => {
writeFileSync(
testFilePath,
'<html><body><h1>Test File Access</h1><p>This content was loaded from a local file.</p></body></html>'
);
});
// Clean up test file after tests
afterAll(() => {
try {
unlinkSync(testFilePath);
} catch {
// Ignore if file doesn't exist
}
});
afterEach(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
describe('without allowFileAccess flag', () => {
it('should fail to load file:// URL content by default', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
});
const page = browser.getPage();
// Navigate to file:// URL - this should work for navigation
// but Chromium restricts what the page can do
await page.goto(testFileUrl);
// The page should load but let's verify the URL
const url = page.url();
expect(url).toBe(testFileUrl);
// Content should be accessible when navigating directly
const content = await page.content();
expect(content).toContain('Test File Access');
});
});
describe('with allowFileAccess flag', () => {
it('should load file:// URL with allowFileAccess enabled', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
allowFileAccess: true,
});
const page = browser.getPage();
await page.goto(testFileUrl);
// Verify the page loaded correctly
const url = page.url();
expect(url).toBe(testFileUrl);
// Verify content is accessible
const heading = await page.locator('h1').textContent();
expect(heading).toBe('Test File Access');
const paragraph = await page.locator('p').textContent();
expect(paragraph).toBe('This content was loaded from a local file.');
});
it('should allow file:// URL to access other local files via XMLHttpRequest', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
allowFileAccess: true,
});
const page = browser.getPage();
await page.goto(testFileUrl);
// With allowFileAccess, XMLHttpRequest to local files should work
// This is the key difference - without the flag, this would be blocked
const canAccessFiles = await page.evaluate(() => {
return new Promise<boolean>((resolve) => {
try {
// XMLHttpRequest is the traditional way to test --allow-file-access-from-files
const xhr = new XMLHttpRequest();
xhr.open('GET', window.location.href, true);
xhr.onload = () => resolve(xhr.status === 0 || xhr.status === 200);
xhr.onerror = () => resolve(false);
xhr.send();
} catch {
resolve(false);
}
});
});
expect(canAccessFiles).toBe(true);
});
});
describe('combined with other options', () => {
it('should work with allowFileAccess and custom user-agent', async () => {
const customUA = 'FileAccessTestBot/1.0';
browser = new BrowserManager();
await browser.launch({
headless: true,
allowFileAccess: true,
userAgent: customUA,
});
const page = browser.getPage();
await page.goto(testFileUrl);
// Verify file access works
const content = await page.locator('h1').textContent();
expect(content).toBe('Test File Access');
// Verify user-agent is set
const ua = await page.evaluate(() => navigator.userAgent);
expect(ua).toBe(customUA);
});
it('should work with allowFileAccess and custom args', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
allowFileAccess: true,
args: ['--disable-blink-features=AutomationControlled'],
});
const page = browser.getPage();
await page.goto(testFileUrl);
// Verify file access works
const content = await page.locator('h1').textContent();
expect(content).toBe('Test File Access');
// Verify webdriver is hidden (from custom arg)
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
});
});
});
-71
View File
@@ -1,71 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseCommand } from '../src/protocol.js';
describe('keyboard command validation', () => {
it('accepts keyboard type with text', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type', text: 'hello' })
);
expect(result.success).toBe(true);
});
it('accepts keyboard insertText with text', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'insertText', text: 'hello' })
);
expect(result.success).toBe(true);
});
it('accepts keyboard press with keys', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'press', keys: 'Enter' })
);
expect(result.success).toBe(true);
});
it('accepts legacy keyboard (no subaction) with keys', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', keys: 'Enter' })
);
expect(result.success).toBe(true);
});
it('rejects keyboard type without text', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type' })
);
expect(result.success).toBe(false);
if (!result.success) expect(result.error).toContain('requires text');
});
it('rejects keyboard insertText without text', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'insertText' })
);
expect(result.success).toBe(false);
if (!result.success) expect(result.error).toContain('requires text');
});
it('rejects keyboard press without keys', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'press' })
);
expect(result.success).toBe(false);
if (!result.success) expect(result.error).toContain('requires keys');
});
it('rejects legacy keyboard (no subaction) without keys', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard' })
);
expect(result.success).toBe(false);
if (!result.success) expect(result.error).toContain('requires keys');
});
it('accepts keyboard type with delay option', () => {
const result = parseCommand(
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type', text: 'hello', delay: 50 })
);
expect(result.success).toBe(true);
});
});
-158
View File
@@ -1,158 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { BrowserManager } from '../src/browser.js';
describe('Launch Options', () => {
let browser: BrowserManager;
afterEach(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
describe('browser args', () => {
it('should launch with custom args to disable webdriver detection', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
});
const page = browser.getPage();
await page.goto('about:blank');
// Check that navigator.webdriver is false
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
});
it('should launch with multiple args', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
],
});
expect(browser.isLaunched()).toBe(true);
});
it('should launch without args (default behavior)', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
});
const page = browser.getPage();
await page.goto('about:blank');
// Default Playwright behavior - webdriver is true
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(true);
});
});
describe('custom user-agent', () => {
it('should launch with custom user-agent', async () => {
const customUA = 'CustomTestBot/1.0';
browser = new BrowserManager();
await browser.launch({
headless: true,
userAgent: customUA,
});
const page = browser.getPage();
await page.goto('about:blank');
const ua = await page.evaluate(() => navigator.userAgent);
expect(ua).toBe(customUA);
});
it('should use default user-agent when not specified', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
});
const page = browser.getPage();
await page.goto('about:blank');
const ua = await page.evaluate(() => navigator.userAgent);
// Default UA should contain Chrome/Chromium
expect(ua).toContain('Chrome');
});
});
describe('proxy configuration', () => {
it('should accept proxy configuration', async () => {
browser = new BrowserManager();
// Note: This test just verifies the proxy option is accepted without error
// Actual proxy testing requires a running proxy server
await browser.launch({
headless: true,
proxy: {
server: 'http://localhost:8080',
},
});
expect(browser.isLaunched()).toBe(true);
});
it('should accept proxy with bypass list', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
proxy: {
server: 'http://localhost:8080',
bypass: 'localhost,*.internal.com',
},
});
expect(browser.isLaunched()).toBe(true);
});
it('should fail connection when proxy is unreachable (proves proxy is being used)', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
proxy: {
server: 'http://127.0.0.1:59999', // Non-existent proxy
},
});
const page = browser.getPage();
// Navigation should fail because proxy is unreachable
// This proves the proxy setting is actually being used
await expect(page.goto('https://example.com', { timeout: 5000 })).rejects.toThrow();
});
});
describe('combined options', () => {
it('should launch with args, user-agent, and proxy combined', async () => {
const customUA = 'CombinedTestBot/2.0';
browser = new BrowserManager();
await browser.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
userAgent: customUA,
proxy: {
server: 'http://localhost:8080',
bypass: 'localhost',
},
});
const page = browser.getPage();
await page.goto('about:blank');
// Verify user-agent
const ua = await page.evaluate(() => navigator.userAgent);
expect(ua).toBe(customUA);
// Verify webdriver is hidden
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
});
});
});
-85
View File
@@ -1,85 +0,0 @@
/**
* Integration test for @sparticuz/chromium compatibility
* This tests the executablePath option with a serverless-optimized Chromium build
*
* Note: @sparticuz/chromium only works on Linux (designed for AWS Lambda).
* This test will skip on non-Linux platforms.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { BrowserManager } from '../src/browser.js';
import * as os from 'os';
const isLinux = os.platform() === 'linux';
// Only run if @sparticuz/chromium is available AND we're on Linux
const canRunTest = await (async () => {
if (!isLinux) {
console.log('Skipping @sparticuz/chromium test: only runs on Linux');
return false;
}
try {
await import('@sparticuz/chromium');
return true;
} catch {
console.log('Skipping @sparticuz/chromium test: package not installed');
return false;
}
})();
describe.skipIf(!canRunTest)('Serverless Chromium Integration', () => {
let browser: BrowserManager;
let chromiumPath: string;
it('should get executable path from @sparticuz/chromium', async () => {
const chromium = await import('@sparticuz/chromium');
chromiumPath = await chromium.default.executablePath();
expect(chromiumPath).toBeTruthy();
expect(typeof chromiumPath).toBe('string');
console.log('Chromium executable path:', chromiumPath);
});
it('should launch browser with custom executablePath', async () => {
const chromium = await import('@sparticuz/chromium');
chromiumPath = await chromium.default.executablePath();
browser = new BrowserManager();
await browser.launch({
headless: true,
executablePath: chromiumPath,
});
expect(browser.isLaunched()).toBe(true);
});
it('should navigate to a page', async () => {
const page = browser.getPage();
await page.goto('https://example.com');
expect(page.url()).toBe('https://example.com/');
});
it('should get page title', async () => {
const page = browser.getPage();
const title = await page.title();
expect(title).toBe('Example Domain');
});
it('should take snapshot with refs', async () => {
const { tree, refs } = await browser.getSnapshot();
expect(tree).toContain('Example Domain');
expect(typeof refs).toBe('object');
expect(Object.keys(refs).length).toBeGreaterThan(0);
});
it('should take screenshot', async () => {
const page = browser.getPage();
const buffer = await page.screenshot();
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
});
afterAll(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
});
-28
View File
@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2024"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"src/**/*.test.ts",
"opensrc"
]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.eval.ts'],
testTimeout: 30000,
},
});