diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3560fe6cd..1a105868f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -3,8 +3,8 @@ "name": "agent-browser", "description": "Browser automation for AI agents", "owner": { - "name": "Vercel", - "email": "support@vercel.com" + "name": "Leonardo Interactive", + "email": "ariel.rahmane@leonardo.ai" }, "plugins": [ { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11e958d85..8d41a46b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write + packages: write jobs: check-release: @@ -28,7 +29,7 @@ jobs: LOCAL_VERSION=$(node -p "require('./package.json').version") echo "Local version: $LOCAL_VERSION" - NPM_VERSION=$(npm view agent-browser version 2>/dev/null || echo "0.0.0") + NPM_VERSION=$(npm view @leonardo-interactive/agent-browser version 2>/dev/null || echo "0.0.0") echo "npm version: $NPM_VERSION" if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then @@ -183,7 +184,7 @@ jobs: with: node-version: '22' cache: pnpm - registry-url: 'https://registry.npmjs.org' + registry-url: 'https://npm.pkg.github.com' - name: Install dependencies run: pnpm install --frozen-lockfile @@ -238,7 +239,7 @@ jobs: - name: Publish to npm run: pnpm publish --no-git-checks env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} github-release: name: Create GitHub Release @@ -285,12 +286,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build dashboard - run: pnpm --filter dashboard build - - - name: Create dashboard.zip - run: cd packages/dashboard/out && zip -r ../../../dashboard.zip . - - name: Extract changelog entry run: | VERSION="${{ needs.check-release.outputs.version }}" @@ -310,13 +305,13 @@ jobs: if gh release view "$TAG" &>/dev/null; then echo "Release $TAG already exists, uploading assets..." - gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber + gh release upload "$TAG" bin/agent-browser-* --clobber else echo "Creating release $TAG..." gh release create "$TAG" \ --title "$TAG" \ --notes-file /tmp/release-notes.md \ - bin/agent-browser-* dashboard.zip + bin/agent-browser-* fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5a40802b7..8ab46fb21 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,9 @@ docs/package-lock.json # next .next/ out/ + +# Claude +.claude/ + +# Dev Internal +.dev-internal/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..b1b0a6185 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@leonardo-interactive:registry=https://npm.pkg.github.com diff --git a/AGENTS.md b/AGENTS.md index c631638a8..a0d844bf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,18 +20,10 @@ When adding or changing user-facing features (new flags, commands, behaviors, en 1. `cli/src/output.rs` — `--help` output (flags list, examples, environment variables) 2. `README.md` — Options table, relevant feature sections, examples 3. `skills/agent-browser/SKILL.md` — so AI agents know about the feature -4. `docs/src/app/` — the Next.js docs site (MDX pages) -5. Inline doc comments in the relevant source files +4. Inline doc comments in the relevant source files This applies to changes that either human users or AI agents would need to know about. Do not skip any of these locations. -In the `docs/src/app/` MDX files, always use HTML `` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site. - -## Dashboard (packages/dashboard) - -- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead. -- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case. - ## Releasing Releases are manual, single-PR affairs. There is no changesets automation. The maintainer controls the changelog voice and format. @@ -42,10 +34,9 @@ To prepare a release: 2. Bump `version` in `package.json` 3. Run `pnpm version:sync` to update `cli/Cargo.toml`, `cli/Cargo.lock`, and `packages/dashboard/package.json` 4. Write the changelog entry in `CHANGELOG.md` at the top, under a new `## ` heading, wrapped in `` and `` markers -5. Add a matching entry to `docs/src/app/changelog/page.mdx` at the top (below the `# Changelog` heading) -6. Open a PR and merge to `main` +5. Open a PR and merge to `main` -When the PR merges, CI compares `package.json` version to what's on npm. If it differs, it builds all 7 platform binaries, publishes to npm, and creates the GitHub release automatically. The GitHub release body is extracted from the content between the `` and `` markers in `CHANGELOG.md`. +When the PR merges, CI compares `package.json` version to what's on GitHub Packages. If it differs, it builds all 7 platform binaries, publishes to GitHub Packages, and creates the GitHub release automatically. The GitHub release body is extracted from the content between the `` and `` markers in `CHANGELOG.md`. ### Writing the changelog @@ -122,88 +113,3 @@ The e2e tests live in `cli/src/native/e2e_tests.rs` and cover: launch/close, nav cd cli && cargo fmt -- --check # Check formatting cd cli && cargo clippy # Lint ``` - -## Windows Debugging - -A remote Windows Server 2022 EC2 instance is available for debugging Windows-specific issues. It uses AWS Systems Manager (SSM) with no SSH or open ports. Commands run via `aws ssm send-command` and return stdout/stderr. - -### Prerequisites - -The instance must be provisioned first (one-time, by a human): - -```bash -./scripts/windows-debug/provision.sh -``` - -Requires: AWS CLI v2 configured with `ec2:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `ssm:SendCommand`, `ssm:GetCommandInvocation` permissions and a default VPC. - -### Usage - -Start the instance (if stopped): - -```bash -./scripts/windows-debug/start.sh -``` - -Run a command on Windows: - -```bash -./scripts/windows-debug/run.sh "" -``` - -Sync the current git branch and rebuild: - -```bash -./scripts/windows-debug/sync.sh -``` - -Stop the instance when done (avoids cost): - -```bash -./scripts/windows-debug/stop.sh -``` - -### Common Workflows - -Run unit tests on Windows: - -```bash -./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test --manifest-path cli\Cargo.toml" -``` - -Run e2e tests on Windows: - -```bash -./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1" -``` - -Check bootstrap progress (first boot only): - -```bash -./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log" -``` - -The repo lives at `C:\agent-browser` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH. - - - -## Source Code Reference - -Source code for dependencies is available in `opensrc/` for deeper understanding of implementation details. - -See `opensrc/sources.json` for the list of available packages and their versions. - -Use this source code when you need to understand how a package works internally, not just its types/interface. - -### Fetching Additional Source Code - -To fetch source code for a package or repository you need to understand, run: - -```bash -npx opensrc # npm package (e.g., npx opensrc zod) -npx opensrc pypi: # Python package (e.g., npx opensrc pypi:requests) -npx opensrc crates: # Rust crate (e.g., npx opensrc crates:serde) -npx opensrc / # GitHub repo (e.g., npx opensrc vercel/ai) -``` - - diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd135236..9038e9caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # agent-browser +## 0.23.4-leonardo.1 + + +### Security Hardening + +Leonardo's hardened fork of agent-browser v0.23.4. This release removes dangerous commands, flags, and capabilities from the Rust binary so AI agents cannot bypass security controls. + +**Removed commands:** `eval`, `inspect`, `auth` (all subcommands), `connect`, `stream` (all subcommands), `clipboard` (all subcommands), `dashboard` (all subcommands), `set credentials` + +**Removed flags:** `--cdp`, `--auto-connect`, `--provider` / `-p`, `--extension`, `--executable-path`, `--allow-file-access` + +**Hardened flags (config-file-only):** `--allowed-domains` and `--action-policy` can no longer be overridden via CLI flags or environment variables. They are only loadable from `agent-browser.json`. + +**Removed modules:** Dashboard UI, inspect server. Provider module kept as dead code with hard rejection in launch handler. + +**Distribution:** Published as `@leonardo-interactive/agent-browser` to GitHub Packages. + + ## 0.23.4 diff --git a/LICENSE b/LICENSE index 8226d363f..e43ed118b 100644 --- a/LICENSE +++ b/LICENSE @@ -187,6 +187,7 @@ APPENDIX: How to apply the Apache License to your work. identification within third-party archives. Copyright 2025 Vercel Inc. +Copyright 2026 Leonardo Interactive Pty Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 8ede376c9..fcc5893bc 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,22 @@ # agent-browser -Browser automation CLI for AI agents. Fast native Rust CLI. +Leonardo's security-hardened fork of [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser) (v0.23.4). Dangerous commands and flags have been removed at the Rust source level so AI agents cannot bypass security controls. ## Installation ### Global Installation (recommended) -Installs the native Rust binary: - -```bash -npm install -g agent-browser -agent-browser install # Download Chrome from Chrome for Testing (first time only) -``` - -### Project Installation (local dependency) - -For projects that want to pin the version in `package.json`: - -```bash -npm install agent-browser -agent-browser install -``` - -Then use via `package.json` scripts or by invoking `agent-browser` directly. - -### Homebrew (macOS) - -```bash -brew install agent-browser -agent-browser install # Download Chrome from Chrome for Testing (first time only) -``` - -### Cargo (Rust) - ```bash -cargo install agent-browser +pnpm add -g @leonardo-interactive/agent-browser agent-browser install # Download Chrome from Chrome for Testing (first time only) ``` ### From Source ```bash -git clone https://github.com/vercel-labs/agent-browser +git clone https://github.com/Leonardo-Interactive/agent-browser cd agent-browser pnpm install -pnpm build pnpm build:native # Requires Rust (https://rustup.rs) pnpm link --global # Makes agent-browser available globally agent-browser install @@ -123,11 +95,6 @@ agent-browser screenshot --screenshot-dir ./shots # Save to custom directory agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80 agent-browser pdf # Save as PDF agent-browser snapshot # Accessibility tree with refs (best for AI) -agent-browser eval # Run JavaScript (-b for base64, --stdin for piped input) -agent-browser connect # Connect to browser via CDP -agent-browser stream enable [--port ] # Start runtime WebSocket streaming -agent-browser stream status # Show runtime streaming state and bound port -agent-browser stream disable # Stop runtime WebSocket streaming agent-browser close # Close browser (aliases: quit, exit) agent-browser close --all # Close all active sessions ``` @@ -141,7 +108,6 @@ agent-browser get value # Get input value agent-browser get attr # Get attribute agent-browser get title # Get page title agent-browser get url # Get current URL -agent-browser get cdp-url # Get CDP WebSocket URL (for DevTools, debugging) agent-browser get count # Count matching elements agent-browser get box # Get bounding box agent-browser get styles # Get computed styles @@ -220,15 +186,6 @@ echo '[ agent-browser batch --bail < commands.json ``` -### Clipboard - -```bash -agent-browser clipboard read # Read text from clipboard -agent-browser clipboard write "Hello, World!" # Write text to clipboard -agent-browser clipboard copy # Copy current selection (Ctrl+C) -agent-browser clipboard paste # Paste from clipboard (Ctrl+V) -``` - ### Mouse Control ```bash @@ -246,7 +203,6 @@ agent-browser set device # Emulate device ("iPhone 14") agent-browser set geo # Set geolocation agent-browser set offline [on|off] # Toggle offline mode agent-browser set headers # Extra HTTP headers -agent-browser set credentials

# HTTP basic auth agent-browser set media [dark|light] # Emulate color scheme ``` @@ -339,7 +295,6 @@ agent-browser console --clear # Clear console agent-browser errors # View page errors (uncaught JavaScript exceptions) agent-browser errors --clear # Clear errors agent-browser highlight # Highlight element -agent-browser inspect # Open Chrome DevTools for the active page agent-browser state save # Save auth state agent-browser state load # Load auth state agent-browser state list # List saved state files @@ -376,36 +331,7 @@ agent-browser provides multiple ways to persist login sessions so you don't re-a |----------|----------|------------| | **Persistent profile** | Full browser state (cookies, IndexedDB, service workers, cache) across restarts | `--profile ` / `AGENT_BROWSER_PROFILE` | | **Session persistence** | Auto-save/restore cookies + localStorage by name | `--session-name ` / `AGENT_BROWSER_SESSION_NAME` | -| **Import from your browser** | Grab auth from a Chrome session you already logged into | `--auto-connect` + `state save` | | **State file** | Load a previously saved state JSON on launch | `--state ` / `AGENT_BROWSER_STATE` | -| **Auth vault** | Store credentials locally (encrypted), login by name | `auth save` / `auth login` | - -### Import auth from your browser - -If you are already logged in to a site in Chrome, you can grab that auth state and reuse it: - -```bash -# 1. Launch Chrome with remote debugging enabled -# macOS: -"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 -# Or use --auto-connect to discover an already-running Chrome - -# 2. Connect and save the authenticated state -agent-browser --auto-connect state save ./my-auth.json - -# 3. Use the saved auth in future sessions -agent-browser --state ./my-auth.json open https://app.example.com/dashboard - -# 4. Or use --session-name for automatic persistence -agent-browser --session-name myapp state load ./my-auth.json -# From now on, --session-name myapp auto-saves/restores this state -``` - -> **Security notes:** -> - `--remote-debugging-port` exposes full browser control on localhost. Any local process can connect. Only use on trusted machines and close Chrome when done. -> - State files contain session tokens in plaintext. Add them to `.gitignore` and delete when no longer needed. For encryption at rest, set `AGENT_BROWSER_ENCRYPTION_KEY` (see [State Encryption](#state-encryption)). - -For full details on login flows, OAuth, 2FA, cookie-based auth, and the auth vault, see the [Authentication](docs/src/app/sessions/page.mdx) docs. ## Sessions @@ -500,24 +426,19 @@ agent-browser --session-name secure open example.com agent-browser includes security features for safe AI agent deployments. All features are opt-in -- existing workflows are unaffected until you explicitly enable a feature: -- **Authentication Vault** -- Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. `auth login` navigates with `load` and then waits for login form selectors to appear (SPA-friendly, timeout follows the default action timeout). A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github` - **Content Boundary Markers** -- Wrap page output in delimiters so LLMs can distinguish tool output from untrusted content: `--content-boundaries` -- **Domain Allowlist** -- Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain): `--allowed-domains "example.com,*.example.com"`. Sub-resource requests (scripts, images, fetch) and WebSocket/EventSource connections to non-allowed domains are also blocked. Include any CDN domains your target pages depend on (e.g., `*.cdn.example.com`). -- **Action Policy** -- Gate destructive actions with a static policy file: `--action-policy ./policy.json` -- **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions eval,download` +- **Domain Allowlist** -- Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain). Set via `allowedDomains` in `agent-browser.json` (config-file-only, cannot be overridden by CLI flags or env vars). Sub-resource requests (scripts, images, fetch) and WebSocket/EventSource connections to non-allowed domains are also blocked. +- **Action Policy** -- Gate destructive actions with a static policy file. Set via `actionPolicy` in `agent-browser.json` (config-file-only, cannot be overridden by CLI flags or env vars). +- **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions download` - **Output Length Limits** -- Prevent context flooding: `--max-output 50000` | Variable | Description | | ----------------------------------- | ---------------------------------------- | | `AGENT_BROWSER_CONTENT_BOUNDARIES` | Wrap page output in boundary markers | | `AGENT_BROWSER_MAX_OUTPUT` | Max characters for page output | -| `AGENT_BROWSER_ALLOWED_DOMAINS` | Comma-separated allowed domain patterns | -| `AGENT_BROWSER_ACTION_POLICY` | Path to action policy JSON file | | `AGENT_BROWSER_CONFIRM_ACTIONS` | Action categories requiring confirmation | | `AGENT_BROWSER_CONFIRM_INTERACTIVE` | Enable interactive confirmation prompts | -See [Security documentation](https://agent-browser.dev/security) for details. - ## Snapshot Options The `snapshot` command supports filtering to reduce output size: @@ -570,15 +491,11 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--profile ` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) | | `--state ` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) | | `--headers ` | Set HTTP headers scoped to the URL's origin | -| `--executable-path ` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | -| `--extension ` | Load browser extension (repeatable; or `AGENT_BROWSER_EXTENSIONS` env) | | `--args ` | Browser launch args, comma or newline separated (or `AGENT_BROWSER_ARGS` env) | | `--user-agent ` | Custom User-Agent string (or `AGENT_BROWSER_USER_AGENT` env) | | `--proxy ` | Proxy server URL with optional auth (or `AGENT_BROWSER_PROXY` env) | | `--proxy-bypass ` | Hosts to bypass proxy (or `AGENT_BROWSER_PROXY_BYPASS` env) | | `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) | -| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) | -| `-p, --provider ` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) | | `--device ` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) | | `--json` | JSON output (for agents) | | `--annotate` | Annotated screenshot with numbered element labels (or `AGENT_BROWSER_ANNOTATE` env) | @@ -586,14 +503,10 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--screenshot-quality ` | JPEG quality 0-100 (or `AGENT_BROWSER_SCREENSHOT_QUALITY` env) | | `--screenshot-format ` | Screenshot format: `png`, `jpeg` (or `AGENT_BROWSER_SCREENSHOT_FORMAT` env) | | `--headed` | Show browser window (not headless) (or `AGENT_BROWSER_HEADED` env) | -| `--cdp ` | Connect via Chrome DevTools Protocol (port or WebSocket URL) | -| `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) | | `--color-scheme ` | Color scheme: `dark`, `light`, `no-preference` (or `AGENT_BROWSER_COLOR_SCHEME` env) | | `--download-path ` | Default download directory (or `AGENT_BROWSER_DOWNLOAD_PATH` env) | | `--content-boundaries` | Wrap page output in boundary markers for LLM safety (or `AGENT_BROWSER_CONTENT_BOUNDARIES` env) | | `--max-output ` | Truncate page output to N characters (or `AGENT_BROWSER_MAX_OUTPUT` env) | -| `--allowed-domains ` | Comma-separated allowed domain patterns (or `AGENT_BROWSER_ALLOWED_DOMAINS` env) | -| `--action-policy ` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) | | `--confirm-actions ` | 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 ` | Browser engine: `chrome` (default), `lightpanda` (or `AGENT_BROWSER_ENGINE` env) | @@ -601,33 +514,6 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--config ` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) | | `--debug` | Debug output | -## Observability Dashboard - -Monitor agent-browser sessions in real time with a local web dashboard showing a live viewport and command activity feed. - -```bash -# Install the dashboard (one time) -agent-browser dashboard install - -# Start the dashboard server (runs in background on port 4848) -agent-browser dashboard start -agent-browser dashboard start --port 8080 # Custom port - -# All sessions are automatically visible in the dashboard -agent-browser open example.com - -# Stop the dashboard -agent-browser dashboard stop -``` - -The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard. - -The dashboard displays: -- **Live viewport** -- real-time JPEG frames from the browser -- **Activity feed** -- chronological command/result stream with timing and expandable details -- **Console output** -- browser console messages (log, warn, error) -- **Session creation** -- create new sessions from the UI with local engines (Chrome, Lightpanda) or cloud providers (Browserbase, Browserless, Browser Use, Kernel) - ## Configuration Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command. @@ -658,7 +544,7 @@ agent-browser --config ./ci-config.json open example.com AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com ``` -All options from the table above can be set in the config file using camelCase keys (e.g., `--executable-path` becomes `"executablePath"`, `--proxy-bypass` becomes `"proxyBypass"`). Unknown keys are ignored for forward compatibility. +All options from the table above can be set in the config file using camelCase keys (e.g., `--proxy-bypass` becomes `"proxyBypass"`). Unknown keys are ignored for forward compatibility. Note: `allowedDomains` and `actionPolicy` can only be set via config file (not CLI flags or env vars). Boolean flags accept an optional `true`/`false` value to override config settings. For example, `--headed false` disables `"headed": true` from config. A bare `--headed` is equivalent to `--headed true`. @@ -826,216 +712,6 @@ For global headers (all domains), use `set headers`: agent-browser set headers '{"X-Custom-Header": "value"}' ``` -## Custom Browser Executable - -Use a custom browser executable instead of the bundled Chromium. This is useful for: - -- **Serverless deployment**: Use lightweight Chromium builds like `@sparticuz/chromium` (~50MB vs ~684MB) -- **System browsers**: Use an existing Chrome/Chromium installation -- **Custom builds**: Use modified browser builds - -### CLI Usage - -```bash -# Via flag -agent-browser --executable-path /path/to/chromium open example.com - -# Via environment variable -AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com -``` - -### Serverless (Vercel) - -Run agent-browser + Chrome in an ephemeral Vercel Sandbox microVM. No external server needed: - -```typescript -import { Sandbox } from "@vercel/sandbox"; - -const sandbox = await Sandbox.create({ runtime: "node24" }); -await sandbox.runCommand("agent-browser", ["open", "https://example.com"]); -const result = await sandbox.runCommand("agent-browser", ["screenshot", "--json"]); -await sandbox.stop(); -``` - -See the [environments example](examples/environments/) for a working demo with a UI and deploy-to-Vercel button. - -### Serverless (AWS Lambda) - -```typescript -import chromium from '@sparticuz/chromium'; -import { execSync } from 'child_process'; - -export async function handler() { - const executablePath = await chromium.executablePath(); - const result = execSync( - `AGENT_BROWSER_EXECUTABLE_PATH=${executablePath} agent-browser open https://example.com && agent-browser snapshot -i --json`, - { encoding: 'utf-8' } - ); - return JSON.parse(result); -} -``` - -## Local Files - -Open and interact with local files (PDFs, HTML, etc.) using `file://` URLs: - -```bash -# Enable file access (required for JavaScript to access local files) -agent-browser --allow-file-access open file:///path/to/document.pdf -agent-browser --allow-file-access open file:///path/to/page.html - -# Take screenshot of a local PDF -agent-browser --allow-file-access open file:///Users/me/report.pdf -agent-browser screenshot report.png -``` - -The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-files`, `--allow-file-access`) that allow `file://` URLs to: - -- Load and render local files -- Access other local files via JavaScript (XHR, fetch) -- Load local resources (images, scripts, stylesheets) - -**Note:** This flag only works with Chromium. For security, it's disabled by default. - -## CDP Mode - -Connect to an existing browser via Chrome DevTools Protocol: - -```bash -# Start Chrome with: google-chrome --remote-debugging-port=9222 - -# Connect once, then run commands without --cdp -agent-browser connect 9222 -agent-browser snapshot -agent-browser tab -agent-browser close - -# Or pass --cdp on each command -agent-browser --cdp 9222 snapshot - -# Connect to remote browser via WebSocket URL -agent-browser --cdp "wss://your-browser-service.com/cdp?token=..." snapshot -``` - -The `--cdp` flag accepts either: - -- A port number (e.g., `9222`) for local connections via `http://localhost:{port}` -- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services - -This enables control of: - -- Electron apps -- Chrome/Chromium instances with remote debugging -- WebView2 applications -- Any browser exposing a CDP endpoint - -### Auto-Connect - -Use `--auto-connect` to automatically discover and connect to a running Chrome instance without specifying a port: - -```bash -# Auto-discover running Chrome with remote debugging -agent-browser --auto-connect open example.com -agent-browser --auto-connect snapshot - -# Or via environment variable -AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot -``` - -Auto-connect discovers Chrome by: - -1. Reading Chrome's `DevToolsActivePort` file from the default user data directory -2. Falling back to probing common debugging ports (9222, 9229) -3. If HTTP-based discovery (`/json/version`, `/json/list`) fails, falling back to a direct WebSocket connection - -This is useful when: - -- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port) -- You want a zero-configuration connection to your existing browser -- You don't want to track which port Chrome is using - -## Streaming (Browser Preview) - -Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent. - -### Streaming - -Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `stream status` to see the bound port and connection state: - -```bash -agent-browser stream status -``` - -To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`: - -```bash -AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com -``` - -You can also manage streaming at runtime with `stream enable`, `stream disable`, and `stream status`: - -```bash -agent-browser stream enable --port 9223 # Re-enable on a specific port -agent-browser stream disable # Stop streaming for the session -``` - -The WebSocket server streams the browser viewport and accepts input events. - -### WebSocket Protocol - -Connect to `ws://localhost:9223` to receive frames and send input: - -**Receive frames:** - -```json -{ - "type": "frame", - "data": "", - "metadata": { - "deviceWidth": 1280, - "deviceHeight": 720, - "pageScaleFactor": 1, - "offsetTop": 0, - "scrollOffsetX": 0, - "scrollOffsetY": 0 - } -} -``` - -**Send mouse events:** - -```json -{ - "type": "input_mouse", - "eventType": "mousePressed", - "x": 100, - "y": 200, - "button": "left", - "clickCount": 1 -} -``` - -**Send keyboard events:** - -```json -{ - "type": "input_keyboard", - "eventType": "keyDown", - "key": "Enter", - "code": "Enter" -} -``` - -**Send touch events:** - -```json -{ - "type": "input_touch", - "eventType": "touchStart", - "touchPoints": [{ "x": 100, "y": 200 }] -} -``` - ## Architecture agent-browser uses a client-daemon architecture: @@ -1074,7 +750,7 @@ The `--help` output is comprehensive and most agents can figure it out from ther Add the skill to your AI coding assistant for richer context: ```bash -npx skills add vercel-labs/agent-browser +npx skills add Leonardo-Interactive/agent-browser ``` This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository, so it stays up to date automatically -- do not copy `SKILL.md` from `node_modules` as it will become stale. @@ -1084,7 +760,7 @@ This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, O Install as a Claude Code skill: ```bash -npx skills add vercel-labs/agent-browser +npx skills add Leonardo-Interactive/agent-browser ``` This adds the skill to `.claude/skills/agent-browser/SKILL.md` in your project. The skill teaches Claude Code the full agent-browser workflow, including the snapshot-ref interaction pattern, session management, and timeout handling. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 60efae21d..3da604b26 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ [[package]] name = "agent-browser" -version = "0.23.4" +version = "0.23.4-leonardo.1" dependencies = [ "aes-gcm", "async-trait", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e239b8cce..add963f75 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "agent-browser" -version = "0.23.4" +version = "0.23.4-leonardo.1" edition = "2021" description = "Fast browser automation CLI for AI agents" license = "Apache-2.0" -repository = "https://github.com/vercel-labs/agent-browser" -homepage = "https://agent-browser.dev" +repository = "https://github.com/Leonardo-Interactive/agent-browser" +homepage = "https://github.com/Leonardo-Interactive/agent-browser" readme = "../README.md" keywords = ["browser", "automation", "ai", "cdp", "chrome"] categories = ["command-line-utilities", "web-programming"] diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 661595c1c..11b67cbd8 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,6 +1,4 @@ -use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::{json, Value}; -use std::io::{self, BufRead}; use crate::color; use crate::flags::Flags; @@ -91,6 +89,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { + Err(ParseError::UnknownCommand { + command: cmd.to_string(), + }) + } + // === Navigation === // Maps to "navigate" action in protocol; reflected in ACTION_CATEGORIES in action-policy.ts "open" | "goto" | "navigate" => { @@ -112,9 +117,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result(headers_json).map_err(|_| { @@ -125,12 +127,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Ok(json!({ "id": id, "action": "back" })), @@ -559,183 +555,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { - // Check for flags: -b/--base64 or --stdin - let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) = - if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") { - (true, false, &rest[1..]) - } else if rest.first() == Some(&"--stdin") { - (false, true, &rest[1..]) - } else { - (false, false, rest.as_slice()) - }; - - let script = if is_stdin { - // Read script from stdin - let stdin = io::stdin(); - let lines: Vec = stdin - .lock() - .lines() - .map(|l| l.unwrap_or_default()) - .collect(); - lines.join("\n") - } else { - let raw_script = script_parts.join(" "); - if is_base64 { - let decoded = - STANDARD - .decode(&raw_script) - .map_err(|_| ParseError::InvalidValue { - message: "Invalid base64 encoding".to_string(), - usage: "eval -b ", - })?; - String::from_utf8(decoded).map_err(|_| ParseError::InvalidValue { - message: "Base64 decoded to invalid UTF-8".to_string(), - usage: "eval -b ", - })? - } else { - raw_script - } - }; - Ok(json!({ "id": id, "action": "evaluate", "script": script })) - } - // === Close === "close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })), - // === Inspect === - "inspect" => Ok(json!({ "id": id, "action": "inspect" })), - - // === Authentication Vault === - "auth" => { - let sub = rest.first().map(|s| s.as_ref()); - match sub { - Some("save") => { - let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { - context: "auth save".to_string(), - usage: "agent-browser auth save --url --username --password ", - })?; - - let mut url = None; - let mut username = None; - let mut password = None; - let mut password_stdin = false; - let mut username_selector = None; - let mut password_selector = None; - let mut submit_selector = None; - - let mut j = 2; - while j < rest.len() { - match rest[j] { - "--url" => { - url = rest.get(j + 1).cloned(); - j += 1; - } - "--username" => { - username = rest.get(j + 1).cloned(); - j += 1; - } - "--password" => { - password = rest.get(j + 1).cloned(); - j += 1; - } - "--password-stdin" => { - password_stdin = true; - } - "--username-selector" => { - username_selector = rest.get(j + 1).cloned(); - j += 1; - } - "--password-selector" => { - password_selector = rest.get(j + 1).cloned(); - j += 1; - } - "--submit-selector" => { - submit_selector = rest.get(j + 1).cloned(); - j += 1; - } - other => { - if other.starts_with("--") { - return Err(ParseError::InvalidValue { - message: format!("unknown flag '{}' for auth save", other), - usage: "agent-browser auth save --url --username --password ", - }); - } - } - } - j += 1; - } - - let url_val = url.ok_or_else(|| ParseError::MissingArguments { - context: "auth save".to_string(), - usage: "agent-browser auth save --url --username --password [--password-stdin]", - })?; - let user_val = username.ok_or_else(|| ParseError::MissingArguments { - context: "auth save".to_string(), - usage: "agent-browser auth save --url --username --password [--password-stdin]", - })?; - - if !password_stdin && password.is_none() { - return Err(ParseError::MissingArguments { - context: "auth save".to_string(), - usage: "agent-browser auth save --url --username --password [--password-stdin]", - }); - } - - let mut cmd = json!({ - "id": id, - "action": "auth_save", - "name": name, - "url": url_val, - "username": user_val, - }); - if password_stdin { - cmd["passwordStdin"] = json!(true); - } - if let Some(pass_val) = password { - cmd["password"] = json!(pass_val); - } - if let Some(us) = username_selector { - cmd["usernameSelector"] = json!(us); - } - if let Some(ps) = password_selector { - cmd["passwordSelector"] = json!(ps); - } - if let Some(ss) = submit_selector { - cmd["submitSelector"] = json!(ss); - } - Ok(cmd) - } - Some("login") => { - let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { - context: "auth login".to_string(), - usage: "agent-browser auth login ", - })?; - Ok(json!({ "id": id, "action": "auth_login", "name": name })) - } - Some("list") => Ok(json!({ "id": id, "action": "auth_list" })), - Some("delete") | Some("remove") => { - let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { - context: "auth delete".to_string(), - usage: "agent-browser auth delete ", - })?; - Ok(json!({ "id": id, "action": "auth_delete", "name": name })) - } - Some("show") => { - let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { - context: "auth show".to_string(), - usage: "agent-browser auth show ", - })?; - Ok(json!({ "id": id, "action": "auth_show", "name": name })) - } - _ => Err(ParseError::UnknownSubcommand { - subcommand: sub.unwrap_or("(none)").to_string(), - valid_options: &["save", "login", "list", "delete", "show"], - }), - } - } - // === Action Confirmation === "confirm" => { let cid = rest.first().ok_or_else(|| ParseError::MissingArguments { @@ -752,108 +574,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { - let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments { - context: "connect".to_string(), - usage: "connect ", - })?; - // Check if it's a URL (ws://, wss://, http://, https://) - if endpoint.starts_with("ws://") - || endpoint.starts_with("wss://") - || endpoint.starts_with("http://") - || endpoint.starts_with("https://") - { - Ok(json!({ "id": id, "action": "launch", "cdpUrl": endpoint })) - } else { - // It's a port number - validate and use cdpPort field - let port: u16 = match endpoint.parse::() { - Ok(0) => { - return Err(ParseError::InvalidValue { - message: "Invalid port: port must be greater than 0".to_string(), - usage: "connect ", - }); - } - Ok(p) if p > 65535 => { - return Err(ParseError::InvalidValue { - message: format!( - "Invalid port: {} is out of range (valid range: 1-65535)", - p - ), - usage: "connect ", - }); - } - Ok(p) => p as u16, - Err(_) => { - return Err(ParseError::InvalidValue { - message: format!( - "Invalid value: '{}' is not a valid port number or URL", - endpoint - ), - usage: "connect ", - }); - } - }; - Ok(json!({ "id": id, "action": "launch", "cdpPort": port })) - } - } - - // === Runtime stream control === - "stream" => match rest.first().copied() { - Some("enable") => { - let mut cmd = json!({ "id": id, "action": "stream_enable" }); - let mut i = 1; - while i < rest.len() { - match rest[i] { - "--port" => { - let value = - rest.get(i + 1) - .ok_or_else(|| ParseError::MissingArguments { - context: "stream enable --port".to_string(), - usage: "stream enable [--port ]", - })?; - let port = - value.parse::().map_err(|_| ParseError::InvalidValue { - message: format!( - "Invalid port: '{}' is not a valid integer", - value - ), - usage: "stream enable [--port ]", - })?; - if port > u16::MAX as u32 { - return Err(ParseError::InvalidValue { - message: format!( - "Invalid port: {} is out of range (valid range: 0-65535)", - port - ), - usage: "stream enable [--port ]", - }); - } - cmd["port"] = json!(port); - i += 2; - } - flag => { - return Err(ParseError::InvalidValue { - message: format!("Unknown flag for stream enable: {}", flag), - usage: "stream enable [--port ]", - }); - } - } - } - Ok(cmd) - } - Some("disable") => Ok(json!({ "id": id, "action": "stream_disable" })), - Some("status") => Ok(json!({ "id": id, "action": "stream_status" })), - Some(sub) => Err(ParseError::UnknownSubcommand { - subcommand: sub.to_string(), - valid_options: &["enable", "disable", "status"], - }), - None => Err(ParseError::MissingArguments { - context: "stream".to_string(), - usage: "stream ", - }), - }, - // === Get === "get" => parse_get(&rest, &id), @@ -1202,27 +922,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result match rest.first().copied() { - Some("read") | None => { - Ok(json!({ "id": id, "action": "clipboard", "operation": "read" })) - } - Some("write") => { - rest.get(1).ok_or_else(|| ParseError::MissingArguments { - context: "clipboard write".to_string(), - usage: "clipboard write ", - })?; - let text = rest[1..].join(" "); - Ok(json!({ "id": id, "action": "clipboard", "operation": "write", "text": text })) - } - Some("copy") => Ok(json!({ "id": id, "action": "clipboard", "operation": "copy" })), - Some("paste") => Ok(json!({ "id": id, "action": "clipboard", "operation": "paste" })), - Some(sub) => Err(ParseError::UnknownSubcommand { - subcommand: sub.to_string(), - valid_options: &["read", "write", "copy", "paste"], - }), - }, - // === State === "state" => { const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"]; @@ -2007,8 +1706,6 @@ fn parse_set(rest: &[&str], id: &str) -> Result { "geolocation", "offline", "headers", - "credentials", - "auth", "media", ]; @@ -2096,17 +1793,6 @@ fn parse_set(rest: &[&str], id: &str) -> Result { })?; Ok(json!({ "id": id, "action": "headers", "headers": headers })) } - Some("credentials") | Some("auth") => { - let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments { - context: "set credentials".to_string(), - usage: "set credentials ", - })?; - let pass = rest.get(2).ok_or_else(|| ParseError::MissingArguments { - context: "set credentials".to_string(), - usage: "set credentials ", - })?; - Ok(json!({ "id": id, "action": "credentials", "username": user, "password": pass })) - } Some("media") => { let color = if rest.contains(&"dark") { "dark" @@ -2283,30 +1969,21 @@ mod tests { headed: false, debug: false, headers: None, - executable_path: None, - extensions: Vec::new(), - cdp: None, profile: None, state: None, proxy: None, proxy_bypass: None, args: None, user_agent: None, - provider: None, ignore_https_errors: false, - allow_file_access: false, device: None, - auto_connect: false, session_name: None, - cli_executable_path: false, - cli_extensions: false, cli_profile: false, cli_state: false, cli_args: false, cli_user_agent: false, cli_proxy: false, cli_proxy_bypass: false, - cli_allow_file_access: false, cli_annotate: false, cli_download_path: false, cli_headed: false, @@ -3056,50 +2733,6 @@ mod tests { // === Clipboard Tests === - #[test] - fn test_clipboard_read_default() { - let cmd = parse_command(&args("clipboard"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "clipboard"); - assert_eq!(cmd["operation"], "read"); - } - - #[test] - fn test_clipboard_read_explicit() { - let cmd = parse_command(&args("clipboard read"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "clipboard"); - assert_eq!(cmd["operation"], "read"); - } - - #[test] - fn test_clipboard_write() { - let cmd = parse_command(&args("clipboard write hello"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "clipboard"); - assert_eq!(cmd["operation"], "write"); - assert_eq!(cmd["text"], "hello"); - } - - #[test] - fn test_clipboard_write_multi_word() { - let cmd = parse_command(&args("clipboard write hello world"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "clipboard"); - assert_eq!(cmd["operation"], "write"); - assert_eq!(cmd["text"], "hello world"); - } - - #[test] - fn test_clipboard_copy() { - let cmd = parse_command(&args("clipboard copy"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "clipboard"); - assert_eq!(cmd["operation"], "copy"); - } - - #[test] - fn test_clipboard_paste() { - let cmd = parse_command(&args("clipboard paste"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "clipboard"); - assert_eq!(cmd["operation"], "paste"); - } - #[test] fn test_clipboard_write_missing_text() { let result = parse_command(&args("clipboard write"), &default_flags()); @@ -3295,54 +2928,6 @@ mod tests { // === Eval Tests === - #[test] - fn test_eval_basic() { - let cmd = parse_command(&args("eval document.title"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "evaluate"); - assert_eq!(cmd["script"], "document.title"); - } - - #[test] - fn test_eval_base64_short_flag() { - // "document.title" in base64 - let cmd = parse_command(&args("eval -b ZG9jdW1lbnQudGl0bGU="), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "evaluate"); - assert_eq!(cmd["script"], "document.title"); - } - - #[test] - fn test_eval_base64_long_flag() { - // "document.title" in base64 - let cmd = parse_command( - &args("eval --base64 ZG9jdW1lbnQudGl0bGU="), - &default_flags(), - ) - .unwrap(); - assert_eq!(cmd["action"], "evaluate"); - assert_eq!(cmd["script"], "document.title"); - } - - #[test] - fn test_eval_base64_with_special_chars() { - // "document.querySelector('[src*=\"_next\"]')" in base64 - let cmd = parse_command( - &args("eval -b ZG9jdW1lbnQucXVlcnlTZWxlY3RvcignW3NyYyo9Il9uZXh0Il0nKQ=="), - &default_flags(), - ) - .unwrap(); - assert_eq!(cmd["action"], "evaluate"); - assert_eq!(cmd["script"], "document.querySelector('[src*=\"_next\"]')"); - } - - #[test] - fn test_eval_base64_invalid() { - let result = parse_command(&args("eval -b !!!invalid!!!"), &default_flags()); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, ParseError::InvalidValue { .. })); - assert!(err.format().contains("Invalid base64")); - } - #[test] fn test_unknown_command() { let result = parse_command(&args("unknowncommand"), &default_flags()); @@ -3580,142 +3165,8 @@ mod tests { // === Connect (CDP) tests === - #[test] - fn test_connect_with_port() { - let cmd = parse_command(&args("connect 9222"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "launch"); - assert_eq!(cmd["cdpPort"], 9222); - assert!(cmd.get("cdpUrl").is_none()); - } - - #[test] - fn test_connect_with_ws_url() { - let input: Vec = vec![ - "connect".to_string(), - "ws://localhost:9222/devtools/browser/abc123".to_string(), - ]; - let cmd = parse_command(&input, &default_flags()).unwrap(); - assert_eq!(cmd["action"], "launch"); - assert_eq!(cmd["cdpUrl"], "ws://localhost:9222/devtools/browser/abc123"); - assert!(cmd.get("cdpPort").is_none()); - } - - #[test] - fn test_connect_with_wss_url() { - let input: Vec = vec![ - "connect".to_string(), - "wss://remote-browser.example.com/cdp?token=xyz".to_string(), - ]; - let cmd = parse_command(&input, &default_flags()).unwrap(); - assert_eq!(cmd["action"], "launch"); - assert_eq!( - cmd["cdpUrl"], - "wss://remote-browser.example.com/cdp?token=xyz" - ); - assert!(cmd.get("cdpPort").is_none()); - } - - #[test] - fn test_connect_with_http_url() { - let input: Vec = vec!["connect".to_string(), "http://localhost:9222".to_string()]; - let cmd = parse_command(&input, &default_flags()).unwrap(); - assert_eq!(cmd["action"], "launch"); - assert_eq!(cmd["cdpUrl"], "http://localhost:9222"); - assert!(cmd.get("cdpPort").is_none()); - } - - #[test] - fn test_connect_missing_argument() { - let result = parse_command(&args("connect"), &default_flags()); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ParseError::MissingArguments { .. } - )); - } - - #[test] - fn test_connect_invalid_port() { - let result = parse_command(&args("connect notanumber"), &default_flags()); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, ParseError::InvalidValue { .. })); - assert!(err.format().contains("not a valid port number or URL")); - } - - #[test] - fn test_connect_port_zero() { - let result = parse_command(&args("connect 0"), &default_flags()); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, ParseError::InvalidValue { .. })); - assert!(err.format().contains("port must be greater than 0")); - } - - #[test] - fn test_connect_port_out_of_range() { - let result = parse_command(&args("connect 65536"), &default_flags()); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, ParseError::InvalidValue { .. })); - assert!(err.format().contains("out of range")); - assert!(err.format().contains("1-65535")); - } - - #[test] - fn test_connect_port_max_valid() { - let cmd = parse_command(&args("connect 65535"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "launch"); - assert_eq!(cmd["cdpPort"], 65535); - } - - #[test] - fn test_connect_port_min_valid() { - let cmd = parse_command(&args("connect 1"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "launch"); - assert_eq!(cmd["cdpPort"], 1); - } - // === Runtime stream control tests === - #[test] - fn test_stream_enable_auto_port() { - let cmd = parse_command(&args("stream enable"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "stream_enable"); - assert!(cmd.get("port").is_none()); - } - - #[test] - fn test_stream_enable_with_port() { - let cmd = parse_command(&args("stream enable --port 9223"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "stream_enable"); - assert_eq!(cmd["port"], 9223); - } - - #[test] - fn test_stream_status() { - let cmd = parse_command(&args("stream status"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "stream_status"); - } - - #[test] - fn test_stream_disable() { - let cmd = parse_command(&args("stream disable"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "stream_disable"); - } - - #[test] - fn test_stream_enable_invalid_port() { - let result = parse_command(&args("stream enable --port abc"), &default_flags()); - assert!(matches!(result, Err(ParseError::InvalidValue { .. }))); - } - - #[test] - fn test_stream_missing_subcommand() { - let result = parse_command(&args("stream"), &default_flags()); - assert!(matches!(result, Err(ParseError::MissingArguments { .. }))); - } - // === Trace Tests === #[test] @@ -4244,12 +3695,6 @@ mod tests { // === Inspect / CDP URL === - #[test] - fn test_inspect() { - let cmd = parse_command(&args("inspect"), &default_flags()).unwrap(); - assert_eq!(cmd["action"], "inspect"); - } - #[test] fn test_get_cdp_url() { let cmd = parse_command(&args("get cdp-url"), &default_flags()).unwrap(); diff --git a/cli/src/connection.rs b/cli/src/connection.rs index d532943cc..dc4284b96 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -196,8 +196,6 @@ pub struct DaemonResult { pub struct DaemonOptions<'a> { pub headed: bool, pub debug: bool, - pub executable_path: Option<&'a str>, - pub extensions: &'a [String], pub args: Option<&'a str>, pub user_agent: Option<&'a str>, pub proxy: Option<&'a str>, @@ -205,10 +203,8 @@ pub struct DaemonOptions<'a> { pub proxy_username: Option<&'a str>, pub proxy_password: Option<&'a str>, pub ignore_https_errors: bool, - pub allow_file_access: bool, pub profile: Option<&'a str>, pub state: Option<&'a str>, - pub provider: Option<&'a str>, pub device: Option<&'a str>, pub session_name: Option<&'a str>, pub download_path: Option<&'a str>, @@ -216,9 +212,7 @@ pub struct DaemonOptions<'a> { pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub engine: Option<&'a str>, - pub auto_connect: bool, pub idle_timeout: Option<&'a str>, - pub cdp: Option<&'a str>, pub no_auto_dialog: bool, } @@ -232,12 +226,6 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if opts.debug { cmd.env("AGENT_BROWSER_DEBUG", "1"); } - if let Some(path) = opts.executable_path { - cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); - } - if !opts.extensions.is_empty() { - cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(",")); - } if let Some(a) = opts.args { cmd.env("AGENT_BROWSER_ARGS", a); } @@ -259,18 +247,12 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if opts.ignore_https_errors { cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } - if opts.allow_file_access { - cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); - } if let Some(prof) = opts.profile { cmd.env("AGENT_BROWSER_PROFILE", prof); } if let Some(st) = opts.state { cmd.env("AGENT_BROWSER_STATE", st); } - if let Some(p) = opts.provider { - cmd.env("AGENT_BROWSER_PROVIDER", p); - } if let Some(d) = opts.device { cmd.env("AGENT_BROWSER_IOS_DEVICE", d); } @@ -292,15 +274,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if let Some(engine) = opts.engine { cmd.env("AGENT_BROWSER_ENGINE", engine); } - if opts.auto_connect { - cmd.env("AGENT_BROWSER_AUTO_CONNECT", "1"); - } if let Some(idle) = opts.idle_timeout { cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle); } - if let Some(cdp) = opts.cdp { - cmd.env("AGENT_BROWSER_CDP", cdp); - } if opts.no_auto_dialog { cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1"); } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 806d4e88a..1ef4a4dda 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -271,20 +271,14 @@ pub struct Flags { pub debug: bool, pub session: String, pub headers: Option, - pub executable_path: Option, - pub cdp: Option, - pub extensions: Vec, pub profile: Option, pub state: Option, pub proxy: Option, pub proxy_bypass: Option, pub args: Option, pub user_agent: Option, - pub provider: Option, pub ignore_https_errors: bool, - pub allow_file_access: bool, pub device: Option, - pub auto_connect: bool, pub session_name: Option, pub annotate: bool, pub color_scheme: Option, @@ -304,15 +298,12 @@ pub struct Flags { // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) - pub cli_executable_path: bool, - pub cli_extensions: bool, pub cli_profile: bool, pub cli_state: bool, pub cli_args: bool, pub cli_user_agent: bool, pub cli_proxy: bool, pub cli_proxy_bypass: bool, - pub cli_allow_file_access: bool, pub cli_annotate: bool, pub cli_download_path: bool, pub cli_headed: bool, @@ -324,22 +315,6 @@ pub fn parse_flags(args: &[String]) -> Flags { std::process::exit(1); }); - let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS") - .ok() - .map(|s| { - s.split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - - let extensions = if !extensions_env.is_empty() { - extensions_env - } else { - config.extensions.unwrap_or_default() - }; - let mut flags = Flags { json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false), headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false), @@ -349,11 +324,6 @@ pub fn parse_flags(args: &[String]) -> Flags { .or(config.session) .unwrap_or_else(|| "default".to_string()), headers: config.headers, - executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH") - .ok() - .or(config.executable_path), - cdp: config.cdp, - extensions, profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile), state: env::var("AGENT_BROWSER_STATE").ok().or(config.state), proxy: env::var("AGENT_BROWSER_PROXY") @@ -374,14 +344,9 @@ pub fn parse_flags(args: &[String]) -> Flags { user_agent: env::var("AGENT_BROWSER_USER_AGENT") .ok() .or(config.user_agent), - provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider), ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS") || config.ignore_https_errors.unwrap_or(false), - allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS") - || config.allow_file_access.unwrap_or(false), device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device), - auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT") - || config.auto_connect.unwrap_or(false), session_name: env::var("AGENT_BROWSER_SESSION_NAME") .ok() .or(config.session_name), @@ -398,18 +363,8 @@ pub fn parse_flags(args: &[String]) -> Flags { .ok() .and_then(|s| s.parse().ok()) .or(config.max_output), - allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS") - .ok() - .map(|s| { - s.split(',') - .map(|d| d.trim().to_lowercase()) - .filter(|d| !d.is_empty()) - .collect() - }) - .or(config.allowed_domains), - action_policy: env::var("AGENT_BROWSER_ACTION_POLICY") - .ok() - .or(config.action_policy), + allowed_domains: config.allowed_domains, + action_policy: config.action_policy, confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS") .ok() .or(config.confirm_actions), @@ -434,15 +389,12 @@ pub fn parse_flags(args: &[String]) -> Flags { .or(config.idle_timeout), no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG") || config.no_auto_dialog.unwrap_or(false), - cli_executable_path: false, - cli_extensions: false, cli_profile: false, cli_state: false, cli_args: false, cli_user_agent: false, cli_proxy: false, cli_proxy_bypass: false, - cli_allow_file_access: false, cli_annotate: false, cli_download_path: false, cli_headed: false, @@ -498,26 +450,6 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } - "--executable-path" => { - if let Some(s) = args.get(i + 1) { - flags.executable_path = Some(s.clone()); - flags.cli_executable_path = true; - i += 1; - } - } - "--extension" => { - if let Some(s) = args.get(i + 1) { - flags.extensions.push(s.clone()); - flags.cli_extensions = true; - i += 1; - } - } - "--cdp" => { - if let Some(s) = args.get(i + 1) { - flags.cdp = Some(s.clone()); - i += 1; - } - } "--profile" => { if let Some(s) = args.get(i + 1) { flags.profile = Some(s.clone()); @@ -560,12 +492,6 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } - "-p" | "--provider" => { - if let Some(p) = args.get(i + 1) { - flags.provider = Some(p.clone()); - i += 1; - } - } "--ignore-https-errors" => { let (val, consumed) = parse_bool_arg(args, i); flags.ignore_https_errors = val; @@ -573,27 +499,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } - "--allow-file-access" => { - let (val, consumed) = parse_bool_arg(args, i); - flags.allow_file_access = val; - flags.cli_allow_file_access = true; - if consumed { - i += 1; - } - } "--device" => { if let Some(d) = args.get(i + 1) { flags.device = Some(d.clone()); i += 1; } } - "--auto-connect" => { - let (val, consumed) = parse_bool_arg(args, i); - flags.auto_connect = val; - if consumed { - i += 1; - } - } "--session-name" => { if let Some(s) = args.get(i + 1) { flags.session_name = Some(s.clone()); @@ -636,23 +547,6 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } - "--allowed-domains" => { - if let Some(s) = args.get(i + 1) { - flags.allowed_domains = Some( - s.split(',') - .map(|d| d.trim().to_lowercase()) - .filter(|d| !d.is_empty()) - .collect(), - ); - i += 1; - } - } - "--action-policy" => { - if let Some(s) = args.get(i + 1) { - flags.action_policy = Some(s.clone()); - i += 1; - } - } "--confirm-actions" => { if let Some(s) = args.get(i + 1) { flags.confirm_actions = Some(s.clone()); @@ -911,20 +805,6 @@ mod tests { assert_eq!(clean, vec!["open", "example.com"]); } - #[test] - fn test_parse_executable_path_flag() { - let flags = parse_flags(&args( - "--executable-path /path/to/chromium open example.com", - )); - assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string())); - } - - #[test] - fn test_parse_executable_path_flag_no_value() { - let flags = parse_flags(&args("--executable-path")); - assert_eq!(flags.executable_path, None); - } - #[test] fn test_clean_args_removes_executable_path() { let cleaned = clean_args(&args( @@ -953,37 +833,6 @@ mod tests { assert_eq!(flags.idle_timeout.as_deref(), Some("10000")); } - #[test] - fn test_parse_flags_with_session_and_executable_path() { - let flags = parse_flags(&args( - "--session test --executable-path /custom/chrome open example.com", - )); - assert_eq!(flags.session, "test"); - assert_eq!(flags.executable_path, Some("/custom/chrome".to_string())); - } - - #[test] - fn test_cli_executable_path_tracking() { - // When --executable-path is passed via CLI, cli_executable_path should be true - let flags = parse_flags(&args("--executable-path /path/to/chrome snapshot")); - assert!(flags.cli_executable_path); - assert_eq!(flags.executable_path, Some("/path/to/chrome".to_string())); - } - - #[test] - fn test_cli_executable_path_not_set_without_flag() { - // When no --executable-path is passed, cli_executable_path should be false - // (even if env var sets executable_path to Some value, which we can't test here) - let flags = parse_flags(&args("snapshot")); - assert!(!flags.cli_executable_path); - } - - #[test] - fn test_cli_extension_tracking() { - let flags = parse_flags(&args("--extension /path/to/ext snapshot")); - assert!(flags.cli_extensions); - } - #[test] fn test_cli_profile_tracking() { let flags = parse_flags(&args("--profile /path/to/profile snapshot")); @@ -1018,13 +867,9 @@ mod tests { #[test] fn test_cli_multiple_flags_tracking() { - let flags = parse_flags(&args( - "--executable-path /chrome --profile /profile --proxy http://proxy snapshot", - )); - assert!(flags.cli_executable_path); + let flags = parse_flags(&args("--profile /profile --proxy http://proxy snapshot")); assert!(flags.cli_profile); assert!(flags.cli_proxy); - assert!(!flags.cli_extensions); assert!(!flags.cli_state); } @@ -1319,19 +1164,6 @@ mod tests { assert!(!flags.ignore_https_errors); } - #[test] - fn test_allow_file_access_false() { - let flags = parse_flags(&args("--allow-file-access false open")); - assert!(!flags.allow_file_access); - assert!(flags.cli_allow_file_access); - } - - #[test] - fn test_auto_connect_false() { - let flags = parse_flags(&args("--auto-connect false open")); - assert!(!flags.auto_connect); - } - #[test] fn test_clean_args_removes_bool_flag_with_value() { let cleaned = clean_args(&args("--headed false --debug true open example.com")); diff --git a/cli/src/install.rs b/cli/src/install.rs index 7ad365143..9c04f358c 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -713,113 +713,3 @@ pub fn get_dashboard_dir() -> PathBuf { .join(".agent-browser") .join("dashboard") } - -const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION"); - -fn dashboard_download_url() -> String { - format!( - "https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip", - DASHBOARD_VERSION - ) -} - -pub fn run_dashboard_install() { - println!("{}", color::cyan("Installing dashboard...")); - - let dest = get_dashboard_dir(); - - if dest.join("index.html").exists() { - println!( - "{} Dashboard is already installed at {}", - color::success_indicator(), - dest.display() - ); - return; - } - - let url = dashboard_download_url(); - println!(" Downloading dashboard v{}", DASHBOARD_VERSION); - println!(" {}", url); - - 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 bytes = match rt.block_on(download_bytes(&url)) { - Ok(b) => b, - Err(e) => { - eprintln!("{} {}", color::error_indicator(), e); - eprintln!(" The dashboard may not be available for this version yet."); - eprintln!(" You can build it locally: cd packages/dashboard && pnpm build"); - exit(1); - } - }; - - match extract_dashboard_zip(bytes, &dest) { - Ok(()) => { - println!( - "{} Dashboard v{} installed successfully", - color::success_indicator(), - DASHBOARD_VERSION - ); - println!(" Location: {}", dest.display()); - } - Err(e) => { - let _ = fs::remove_dir_all(&dest); - eprintln!("{} {}", color::error_indicator(), e); - exit(1); - } - } -} - -fn extract_dashboard_zip(bytes: Vec, 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 rel_path = enclosed.to_string_lossy().to_string(); - - if rel_path.is_empty() || file.is_dir() { - if file.is_dir() { - let out_dir = dest.join(&rel_path); - let _ = fs::create_dir_all(&out_dir); - } - continue; - } - - let out_path = dest.join(&rel_path); - if !out_path.starts_with(dest) { - continue; - } - - 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))?; - } - - Ok(()) -} diff --git a/cli/src/main.rs b/cli/src/main.rs index 979f592c5..c29420ca2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -198,186 +198,6 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { } } -fn get_dashboard_pid_path() -> std::path::PathBuf { - get_socket_dir().join("dashboard.pid") -} - -fn is_pid_alive(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(pid as i32, 0) == 0 } - } - #[cfg(windows)] - { - unsafe { - let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if handle != 0 { - CloseHandle(handle); - true - } else { - false - } - } - } -} - -fn run_dashboard_start(port: u16, json_mode: bool) { - let pid_path = get_dashboard_pid_path(); - - // Check if already running - if let Ok(pid_str) = fs::read_to_string(&pid_path) { - if let Ok(pid) = pid_str.trim().parse::() { - if is_pid_alive(pid) { - if json_mode { - print_json_value(json!({ - "success": true, - "data": { "port": port, "pid": pid, "already_running": true }, - })); - } else { - println!("Dashboard already running at http://localhost:{}", port); - } - return; - } - } - let _ = fs::remove_file(&pid_path); - } - - let socket_dir = get_socket_dir(); - if !socket_dir.exists() { - let _ = fs::create_dir_all(&socket_dir); - } - - let exe_path = match env::current_exe() { - Ok(p) => p.canonicalize().unwrap_or(p), - Err(e) => { - if json_mode { - print_json_error(format!("Failed to get executable path: {}", e)); - } else { - eprintln!( - "{} Failed to get executable path: {}", - color::error_indicator(), - e - ); - } - exit(1); - } - }; - - let mut cmd = std::process::Command::new(&exe_path); - cmd.env("AGENT_BROWSER_DASHBOARD", "1") - .env("AGENT_BROWSER_DASHBOARD_PORT", port.to_string()); - - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - } - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - const DETACHED_PROCESS: u32 = 0x00000008; - cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); - } - - match cmd - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - Ok(child) => { - let pid = child.id(); - let _ = fs::write(&pid_path, pid.to_string()); - - if json_mode { - print_json_value(json!({ - "success": true, - "data": { "port": port, "pid": pid }, - })); - } else { - println!("Dashboard started at http://localhost:{}", port); - } - } - Err(e) => { - if json_mode { - print_json_error(format!("Failed to start dashboard: {}", e)); - } else { - eprintln!( - "{} Failed to start dashboard: {}", - color::error_indicator(), - e - ); - } - exit(1); - } - } -} - -fn run_dashboard_stop(json_mode: bool) { - let pid_path = get_dashboard_pid_path(); - - let pid_str = match fs::read_to_string(&pid_path) { - Ok(s) => s, - Err(_) => { - if json_mode { - print_json_value( - json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }), - ); - } else { - println!("Dashboard is not running"); - } - return; - } - }; - - let pid: u32 = match pid_str.trim().parse() { - Ok(p) => p, - Err(_) => { - let _ = fs::remove_file(&pid_path); - if json_mode { - print_json_value( - json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }), - ); - } else { - println!("Dashboard is not running"); - } - return; - } - }; - - #[cfg(unix)] - { - unsafe { - libc::kill(pid as i32, libc::SIGTERM); - } - } - #[cfg(windows)] - { - unsafe { - let handle = OpenProcess(1, 0, pid); // PROCESS_TERMINATE = 1 - if handle != 0 { - windows_sys::Win32::System::Threading::TerminateProcess(handle, 0); - CloseHandle(handle); - } - } - } - - let _ = fs::remove_file(&pid_path); - - if json_mode { - print_json_value(json!({ "success": true, "data": { "stopped": true } })); - } else { - println!("{} Dashboard stopped", color::green("✓")); - } -} - fn run_close_all(flags: &Flags) { let socket_dir = get_socket_dir(); let mut sessions: Vec = Vec::new(); @@ -499,17 +319,6 @@ fn main() { return; } - // Standalone dashboard server mode - if env::var("AGENT_BROWSER_DASHBOARD").is_ok() { - let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(4848); - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - rt.block_on(native::stream::run_dashboard_server(port)); - return; - } - let args: Vec = env::args().skip(1).collect(); let flags = parse_flags(&args); let clean = clean_args(&args); @@ -550,38 +359,6 @@ fn main() { return; } - // Handle dashboard subcommand - if clean.first().map(|s| s.as_str()) == Some("dashboard") { - match clean.get(1).map(|s| s.as_str()) { - Some("install") => { - install::run_dashboard_install(); - return; - } - Some("start") | None => { - let port = clean - .iter() - .position(|a| a == "--port") - .and_then(|i| clean.get(i + 1)) - .and_then(|s| s.parse::().ok()) - .unwrap_or(4848); - run_dashboard_start(port, flags.json); - return; - } - Some("stop") => { - run_dashboard_stop(flags.json); - return; - } - Some(unknown) => { - eprintln!( - "{} Unknown dashboard subcommand: {}", - color::error_indicator(), - unknown - ); - exit(1); - } - } - } - // Handle session separately (doesn't need daemon) if clean.first().map(|s| s.as_str()) == Some("session") { run_session(&clean, &flags.session, flags.json); @@ -698,8 +475,6 @@ fn main() { let daemon_opts = DaemonOptions { headed: flags.headed, debug: flags.debug, - executable_path: flags.executable_path.as_deref(), - extensions: &flags.extensions, args: flags.args.as_deref(), user_agent: flags.user_agent.as_deref(), proxy: proxy_server.as_deref(), @@ -707,10 +482,8 @@ fn main() { proxy_username: proxy_username.as_deref(), proxy_password: proxy_password.as_deref(), ignore_https_errors: flags.ignore_https_errors, - allow_file_access: flags.allow_file_access, profile: flags.profile.as_deref(), state: flags.state.as_deref(), - provider: flags.provider.as_deref(), device: flags.device.as_deref(), session_name: flags.session_name.as_deref(), download_path: flags.download_path.as_deref(), @@ -718,9 +491,7 @@ fn main() { action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), engine: flags.engine.as_deref(), - auto_connect: flags.auto_connect, idle_timeout: flags.idle_timeout.as_deref(), - cdp: flags.cdp.as_deref(), no_auto_dialog: flags.no_auto_dialog, }; @@ -741,16 +512,6 @@ fn main() { // variables (since the daemon already uses the env vars when it starts). if daemon_result.already_running { let ignored_flags: Vec<&str> = [ - if flags.cli_executable_path { - Some("--executable-path") - } else { - None - }, - if flags.cli_extensions { - Some("--extension") - } else { - None - }, if flags.cli_profile { Some("--profile") } else { @@ -778,7 +539,6 @@ fn main() { None }, 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_headed.then_some("--headed"), ] @@ -795,247 +555,17 @@ fn main() { } } - // Validate mutually exclusive options - if flags.cdp.is_some() && flags.provider.is_some() { - let msg = "Cannot use --cdp and -p/--provider together"; - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - - if flags.auto_connect && flags.cdp.is_some() { - let msg = "Cannot use --auto-connect and --cdp together"; - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - - if flags.auto_connect && flags.provider.is_some() { - let msg = "Cannot use --auto-connect and -p/--provider together"; - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - - if flags.provider.is_some() && !flags.extensions.is_empty() { - let msg = "Cannot use --extension with -p/--provider (extensions require local browser)"; - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - - if flags.cdp.is_some() && !flags.extensions.is_empty() { - let msg = "Cannot use --extension with --cdp (extensions require local browser)"; - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - - // Auto-connect to existing browser. - // Skip when the daemon was already running — it already holds the connection - // from a previous auto-connect launch, so re-sending the launch command would - // redundantly probe Chrome and may trigger repeated permission prompts (#962). - if flags.auto_connect && !daemon_result.already_running { - let mut launch_cmd = json!({ - "id": gen_id(), - "action": "launch", - "autoConnect": true - }); - - if flags.ignore_https_errors { - launch_cmd["ignoreHTTPSErrors"] = json!(true); - } - - if let Some(ref cs) = flags.color_scheme { - launch_cmd["colorScheme"] = json!(cs); - } - - if let Some(ref dp) = flags.download_path { - launch_cmd["downloadPath"] = json!(dp); - } - - let err = match send_command(launch_cmd, &flags.session) { - Ok(resp) if resp.success => None, - Ok(resp) => Some( - resp.error - .unwrap_or_else(|| "Auto-connect failed".to_string()), - ), - Err(e) => Some(e.to_string()), - }; - - if let Some(msg) = err { - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - } - - // Connect via CDP if --cdp flag is set - // Accepts either a port number (e.g., "9222") or a full URL (e.g., "ws://..." or "wss://...") - // Skip when daemon already running — it already holds the CDP connection. - if let Some(ref cdp_value) = flags.cdp { - // Validate CDP value eagerly (even when daemon is already running) so - // the user gets an immediate error for bad input instead of a silent no-op. - let launch_cmd = if cdp_value.starts_with("ws://") - || cdp_value.starts_with("wss://") - || cdp_value.starts_with("http://") - || cdp_value.starts_with("https://") - { - // It's a URL - use cdpUrl field - json!({ - "id": gen_id(), - "action": "launch", - "cdpUrl": cdp_value - }) - } else { - // It's a port number - validate and use cdpPort field - let cdp_port: u16 = match cdp_value.parse::() { - Ok(0) => { - let msg = "Invalid CDP port: port must be greater than 0".to_string(); - if flags.json { - print_json_error(&msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - Ok(p) if p > 65535 => { - let msg = format!( - "Invalid CDP port: {} is out of range (valid range: 1-65535)", - p - ); - if flags.json { - print_json_error(&msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - Ok(p) => p as u16, - Err(_) => { - let msg = format!( - "Invalid CDP value: '{}' is not a valid port number or URL", - cdp_value - ); - if flags.json { - print_json_error(&msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - }; - json!({ - "id": gen_id(), - "action": "launch", - "cdpPort": cdp_port - }) - }; - - if !daemon_result.already_running { - let mut launch_cmd = launch_cmd; - - if flags.ignore_https_errors { - launch_cmd["ignoreHTTPSErrors"] = json!(true); - } - - if let Some(ref cs) = flags.color_scheme { - launch_cmd["colorScheme"] = json!(cs); - } - - if let Some(ref dp) = flags.download_path { - launch_cmd["downloadPath"] = json!(dp); - } - - let err = match send_command(launch_cmd, &flags.session) { - Ok(resp) if resp.success => None, - Ok(resp) => Some( - resp.error - .unwrap_or_else(|| "CDP connection failed".to_string()), - ), - Err(e) => Some(e.to_string()), - }; - - if let Some(msg) = err { - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - } - } - - // Launch with cloud provider if -p flag is set - // Skip when daemon already running — it already holds the provider connection. - if let Some(ref provider) = flags.provider { - if !daemon_result.already_running { - let mut launch_cmd = json!({ - "id": gen_id(), - "action": "launch", - "provider": provider - }); - - if let Some(ref cs) = flags.color_scheme { - launch_cmd["colorScheme"] = json!(cs); - } - - let err = match send_command(launch_cmd, &flags.session) { - Ok(resp) if resp.success => None, - Ok(resp) => Some( - resp.error - .unwrap_or_else(|| "Provider connection failed".to_string()), - ), - Err(e) => Some(e.to_string()), - }; - - if let Some(msg) = err { - if flags.json { - print_json_error(msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); - } - exit(1); - } - } - } - - // Launch headed browser or configure browser options (without CDP or provider) - if (flags.headed + // Launch headed browser or configure browser options + if flags.headed || flags.cli_headed // User explicitly set --headed (even if false) - || flags.executable_path.is_some() || flags.profile.is_some() || flags.state.is_some() || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some() - || flags.allow_file_access || flags.color_scheme.is_some() || flags.download_path.is_some() || flags.engine.is_some() - || !flags.extensions.is_empty()) - && flags.cdp.is_none() - && flags.provider.is_none() - && !flags.auto_connect { let mut launch_cmd = json!({ "id": gen_id(), @@ -1047,11 +577,6 @@ fn main() { .as_object_mut() .expect("json! macro guarantees object type"); - // Add executable path if specified - if let Some(ref exec_path) = flags.executable_path { - cmd_obj.insert("executablePath".to_string(), json!(exec_path)); - } - // Add profile path if specified if let Some(ref profile_path) = flags.profile { cmd_obj.insert("profile".to_string(), json!(profile_path)); @@ -1091,18 +616,10 @@ fn main() { cmd_obj.insert("args".to_string(), json!(args_vec)); } - if !flags.extensions.is_empty() { - cmd_obj.insert("extensions".to_string(), json!(&flags.extensions)); - } - if flags.ignore_https_errors { launch_cmd["ignoreHTTPSErrors"] = json!(true); } - if flags.allow_file_access { - launch_cmd["allowFileAccess"] = json!(true); - } - if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 7f3f7434b..aeb4162a3 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -11,7 +11,6 @@ use tokio::sync::{broadcast, oneshot, RwLock}; use crate::connection::get_socket_dir; -use super::auth; use super::browser::{should_track_target, BrowserManager, WaitUntil}; use super::cdp::chrome::LaunchOptions; use super::cdp::client::CdpClient; @@ -23,7 +22,6 @@ use super::cdp::types::{ use super::cookies; use super::diff; use super::element::RefMap; -use super::inspect_server::InspectServer; use super::interaction; use super::network::{self, DomainFilter, EventTracker}; use super::policy::{ActionPolicy, ConfirmActions, PolicyResult}; @@ -50,13 +48,6 @@ use super::webdriver::safari; /// to appear before filling/clicking. pub const AUTH_LOGIN_WAIT_UNTIL: WaitUntil = WaitUntil::Load; -/// Poll interval used while waiting for auth form selectors to appear. -const AUTH_LOGIN_SELECTOR_POLL_INTERVAL_MS: u64 = 100; - -/// Time spent trying targeted username selectors before broad text-input -/// fallback selectors are allowed. -const AUTH_LOGIN_PREFERRED_SELECTOR_WINDOW_MS: u64 = 5_000; - pub struct PendingConfirmation { pub action: String, pub cmd: Value, @@ -186,7 +177,6 @@ pub struct DaemonState { pub har_recording: bool, pub har_entries: Vec, pub confirm_actions: Option, - pub inspect_server: Option, pub routes: Arc>>, pub tracked_requests: Vec, pub request_tracking: bool, @@ -249,7 +239,6 @@ impl DaemonState { har_recording: false, har_entries: Vec::new(), confirm_actions: ConfirmActions::from_env(), - inspect_server: None, routes: Arc::new(RwLock::new(Vec::new())), tracked_requests: Vec::new(), request_tracking: false, @@ -1108,23 +1097,12 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "" | "launch" | "close" | "har_stop" - | "credentials_set" - | "credentials_get" - | "credentials_delete" - | "credentials_list" - | "auth_save" - | "auth_show" - | "auth_delete" - | "auth_list" | "state_list" | "state_show" | "state_clear" | "state_clean" | "state_rename" | "device_list" - | "stream_enable" - | "stream_disable" - | "stream_status" ); if !skip_launch { // Check if existing connection is stale and needs re-launch. @@ -1175,11 +1153,8 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "launch" => handle_launch(cmd, state).await, "navigate" => handle_navigate(cmd, state).await, "url" => handle_url(state).await, - "cdp_url" => handle_cdp_url(state), - "inspect" => handle_inspect(state).await, "title" => handle_title(state).await, "content" => handle_content(state).await, - "evaluate" => handle_evaluate(cmd, state).await, "close" => handle_close(state).await, "snapshot" => handle_snapshot(cmd, state).await, "screenshot" => handle_screenshot(cmd, state).await, @@ -1237,10 +1212,6 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "download" => handle_download(cmd, state).await, "diff_snapshot" => handle_diff_snapshot(cmd, state).await, "diff_url" => handle_diff_url(cmd, state).await, - "credentials_set" => handle_credentials_set(cmd).await, - "credentials_get" => handle_credentials_get(cmd).await, - "credentials_delete" => handle_credentials_delete(cmd).await, - "credentials_list" => handle_credentials_list().await, "mouse" => handle_mouse(cmd, state).await, "keyboard" => handle_keyboard(cmd, state).await, "focus" => handle_focus(cmd, state).await, @@ -1267,14 +1238,10 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "addscript" => handle_addscript(cmd, state).await, "addinitscript" => handle_addinitscript(cmd, state).await, "addstyle" => handle_addstyle(cmd, state).await, - "clipboard" => handle_clipboard(cmd, state).await, "wheel" => handle_wheel(cmd, state).await, "device" => handle_device(cmd, state).await, "screencast_start" => handle_screencast_start(cmd, state).await, "screencast_stop" => handle_screencast_stop(state).await, - "stream_enable" => handle_stream_enable(cmd, state).await, - "stream_disable" => handle_stream_disable(state).await, - "stream_status" => handle_stream_status(state).await, "waitforurl" => handle_waitforurl(cmd, state).await, "waitforloadstate" => handle_waitforloadstate(cmd, state).await, "waitforfunction" => handle_waitforfunction(cmd, state).await, @@ -1306,13 +1273,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "unroute" => handle_unroute(cmd, state).await, "requests" => handle_requests(cmd, state).await, "request_detail" => handle_request_detail(cmd, state).await, - "credentials" => handle_http_credentials(cmd, state).await, "emulatemedia" => handle_set_media(cmd, state).await, - "auth_save" => handle_auth_save(cmd).await, - "auth_login" => handle_auth_login(cmd, state).await, - "auth_list" => handle_credentials_list().await, - "auth_delete" => handle_credentials_delete(cmd).await, - "auth_show" => handle_auth_show(cmd).await, "confirm" => handle_confirm(cmd, state).await, "deny" => handle_deny(cmd, state).await, "swipe" => handle_swipe(cmd, state).await, @@ -1517,6 +1478,11 @@ async fn try_auto_restore_state(state: &mut DaemonState) { // --------------------------------------------------------------------------- async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result { + // Hardened build: reject cloud browser providers + if cmd.get("provider").is_some() { + return Err("Cloud browser providers are disabled in this build".to_string()); + } + let headless = cmd .get("headless") .and_then(|v| v.as_bool()) @@ -1968,31 +1934,6 @@ async fn handle_url(state: &DaemonState) -> Result { Ok(json!({ "url": url })) } -fn handle_cdp_url(state: &DaemonState) -> Result { - let mgr = state.browser.as_ref().ok_or("Browser not launched")?; - Ok(json!({ "cdpUrl": mgr.get_cdp_url() })) -} - -async fn handle_inspect(state: &mut DaemonState) -> Result { - let mgr = state.browser.as_ref().ok_or("Browser not launched")?; - - // Shut down any existing inspect server so we always target the current page - if let Some(server) = state.inspect_server.take() { - server.shutdown(); - } - - let target_id = mgr.active_target_id()?.to_string(); - let chrome_hp = mgr.chrome_host_port().to_string(); - let proxy_handle = mgr.client.inspect_handle(); - - let server = InspectServer::start(proxy_handle, target_id, chrome_hp).await?; - let url = format!("http://127.0.0.1:{}", server.port()); - open_url_in_browser(&url); - - state.inspect_server = Some(server); - Ok(json!({ "opened": true, "url": url })) -} - fn open_url_in_browser(url: &str) { #[cfg(target_os = "macos")] let result = std::process::Command::new("open").arg(url).spawn(); @@ -2038,29 +1979,6 @@ async fn handle_content(state: &DaemonState) -> Result { Ok(json!({ "html": html, "origin": url })) } -async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result { - if let Some(ref wb) = state.webdriver_backend { - if state.browser.is_none() { - let script = cmd - .get("script") - .and_then(|v| v.as_str()) - .ok_or("Missing 'script' parameter")?; - let result = wb.evaluate(script).await?; - let url = wb.get_url().await.unwrap_or_default(); - return Ok(json!({ "result": result, "origin": url })); - } - } - let mgr = state.browser.as_ref().ok_or("Browser not launched")?; - let script = cmd - .get("script") - .and_then(|v| v.as_str()) - .ok_or("Missing 'script' parameter")?; - - let result = mgr.evaluate(script, None).await?; - let url = mgr.get_url().await.unwrap_or_default(); - Ok(json!({ "result": result, "origin": url })) -} - async fn handle_close(state: &mut DaemonState) -> Result { if let Some(ref mgr) = state.browser { if let Some(ref session_name) = state.session_name { @@ -2109,10 +2027,6 @@ async fn handle_close(state: &mut DaemonState) -> Result { state.safari_driver = None; state.backend_type = BackendType::Cdp; - if let Some(server) = state.inspect_server.take() { - server.shutdown(); - } - state.ref_map.clear(); Ok(json!({ "closed": true })) } @@ -3239,51 +3153,6 @@ async fn handle_diff_url(cmd: &Value, state: &mut DaemonState) -> Result Result { - let name = cmd - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name'")?; - let username = cmd - .get("username") - .and_then(|v| v.as_str()) - .ok_or("Missing 'username'")?; - let password = cmd - .get("password") - .and_then(|v| v.as_str()) - .ok_or("Missing 'password'")?; - let url = cmd.get("url").and_then(|v| v.as_str()); - auth::credentials_set(name, username, password, url) -} - -async fn handle_credentials_get(cmd: &Value) -> Result { - let name = cmd - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name'")?; - auth::credentials_get(name) -} - -async fn handle_credentials_delete(cmd: &Value) -> Result { - let name = cmd - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name'")?; - auth::credentials_delete(name) -} - -async fn handle_credentials_list() -> Result { - auth::credentials_list() -} - -async fn handle_auth_show(cmd: &Value) -> Result { - let name = cmd - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name'")?; - auth::auth_show(name) -} - async fn handle_mouse(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); @@ -4407,50 +4276,6 @@ async fn handle_addstyle(cmd: &Value, state: &DaemonState) -> Result Result { - let mgr = state.browser.as_ref().ok_or("Browser not launched")?; - let action = cmd - .get("subAction") - .or_else(|| cmd.get("operation")) - .and_then(|v| v.as_str()) - .unwrap_or("read"); - - let session_id = mgr.active_session_id()?.to_string(); - - // cfg! is compile-time; assumes the browser runs on the same OS as the CLI binary. - let modifier: i32 = if cfg!(target_os = "macos") { 4 } else { 2 }; - - match action { - "write" => { - let text = cmd - .get("text") - .or_else(|| cmd.get("value")) - .and_then(|v| v.as_str()) - .ok_or("Missing 'text' parameter")?; - let js = format!( - "navigator.clipboard.writeText({})", - serde_json::to_string(text).unwrap_or_default() - ); - mgr.evaluate(&js, None).await?; - Ok(json!({ "written": text })) - } - "copy" => { - interaction::press_key_with_modifiers(&mgr.client, &session_id, "c", Some(modifier)) - .await?; - Ok(json!({ "copied": true })) - } - "paste" => { - interaction::press_key_with_modifiers(&mgr.client, &session_id, "v", Some(modifier)) - .await?; - Ok(json!({ "pasted": true })) - } - _ => { - let result = mgr.evaluate("navigator.clipboard.readText()", None).await?; - Ok(json!({ "text": result })) - } - } -} - async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); @@ -4622,57 +4447,6 @@ async fn current_stream_status(state: &DaemonState) -> Value { }) } -async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result { - if state.stream_server.is_some() { - return Err("Streaming is already enabled for this session".to_string()); - } - - let requested_port = match cmd.get("port").and_then(|value| value.as_u64()) { - Some(raw) => u16::try_from(raw) - .map_err(|_| format!("Invalid stream port '{}': expected 0-65535", raw))?, - None => 0, - }; - - let (server, client_slot) = - StreamServer::start_without_client(requested_port, state.session_id.clone(), false).await?; - let port = server.port(); - if let Err(err) = write_stream_file(&state.session_id, port) { - server.shutdown().await; - return Err(err); - } - - state.stream_client = Some(client_slot); - state.stream_server = Some(Arc::new(server)); - state.request_tracking = true; - if state.screencasting { - if let Some(ref server) = state.stream_server { - server.set_screencasting(true).await; - } - } - state.update_stream_client().await; - - Ok(current_stream_status(state).await) -} - -async fn handle_stream_disable(state: &mut DaemonState) -> Result { - let Some(server) = state.stream_server.clone() else { - return Err("Streaming is not enabled for this session".to_string()); - }; - - server.shutdown().await; - state.stream_server = None; - state.stream_client = None; - remove_stream_file(&state.session_id)?; - remove_engine_file(&state.session_id); - remove_provider_file(&state.session_id); - - Ok(json!({ "disabled": true })) -} - -async fn handle_stream_status(state: &DaemonState) -> Result { - Ok(current_stream_status(state).await) -} - // --------------------------------------------------------------------------- // Screencast handlers // --------------------------------------------------------------------------- @@ -6614,340 +6388,6 @@ async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result Result { - let mgr = state.browser.as_ref().ok_or("Browser not launched")?; - let session_id = mgr.active_session_id()?.to_string(); - let username = cmd - .get("username") - .and_then(|v| v.as_str()) - .ok_or("Missing 'username' parameter")?; - let password = cmd - .get("password") - .and_then(|v| v.as_str()) - .ok_or("Missing 'password' parameter")?; - - let encoded = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - format!("{}:{}", username, password), - ); - - let mut headers = HashMap::new(); - headers.insert("Authorization".to_string(), format!("Basic {}", encoded)); - network::set_extra_headers(&mgr.client, &session_id, &headers).await?; - - Ok(json!({ "set": true })) -} - -// --------------------------------------------------------------------------- -// Auth handlers -// --------------------------------------------------------------------------- - -/// Wait for any selector in `selectors` to appear and return the first match. -/// -/// This is used by `auth_login` auto-detection so SPA login forms can render -/// after initial navigation without requiring global network-idle. -async fn wait_for_any_selector( - client: &super::cdp::client::CdpClient, - session_id: &str, - selectors: &[&str], - timeout_ms: u64, -) -> Result { - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); - - loop { - for selector in selectors { - let expression = format!( - r#"(() => {{ - const el = document.querySelector({sel}); - if (!el) return false; - - const r = el.getBoundingClientRect(); - const s = window.getComputedStyle(el); - const opacity = parseFloat(s.opacity || '1'); - const isVisible = - r.width > 0 && - r.height > 0 && - s.visibility !== 'hidden' && - s.display !== 'none' && - (!Number.isFinite(opacity) || opacity > 0); - - if (!isVisible) return false; - if (el.matches(':disabled')) return false; - - if (el instanceof HTMLInputElement && el.type === 'hidden') return false; - if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.readOnly) return false; - - return true; - }})()"#, - sel = serde_json::to_string(selector).unwrap_or_default() - ); - - let result: super::cdp::types::EvaluateResult = client - .send_command_typed( - "Runtime.evaluate", - &super::cdp::types::EvaluateParams { - expression, - return_by_value: Some(true), - await_promise: Some(true), - }, - Some(session_id), - ) - .await?; - - if result - .result - .value - .as_ref() - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - return Ok((*selector).to_string()); - } - } - - if tokio::time::Instant::now() >= deadline { - return Err(format!("Wait timed out after {}ms", timeout_ms)); - } - - tokio::time::sleep(tokio::time::Duration::from_millis( - AUTH_LOGIN_SELECTOR_POLL_INTERVAL_MS, - )) - .await; - } -} - -async fn handle_auth_save(cmd: &Value) -> Result { - let name = cmd - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name'")?; - let url = cmd - .get("url") - .and_then(|v| v.as_str()) - .ok_or("Missing 'url'")?; - let username = cmd - .get("username") - .and_then(|v| v.as_str()) - .ok_or("Missing 'username'")?; - let password = cmd - .get("password") - .and_then(|v| v.as_str()) - .ok_or("Missing 'password'")?; - let username_selector = cmd.get("usernameSelector").and_then(|v| v.as_str()); - let password_selector = cmd.get("passwordSelector").and_then(|v| v.as_str()); - let submit_selector = cmd.get("submitSelector").and_then(|v| v.as_str()); - auth::auth_save( - name, - url, - username, - password, - username_selector, - password_selector, - submit_selector, - ) -} - -async fn handle_auth_login(cmd: &Value, state: &mut DaemonState) -> Result { - let name = cmd - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name'")?; - let cred = auth::credentials_get_full(name)?; - if cred.url.is_empty() { - return Err("Credential has no URL".to_string()); - } - let url = cred.url; - let username = cred.username; - let password = cred.password; - - let mgr = state.browser.as_mut().ok_or("Browser not launched")?; - mgr.navigate(&url, AUTH_LOGIN_WAIT_UNTIL).await?; - - let session_id = mgr.active_session_id()?.to_string(); - let auth_timeout_ms = mgr.default_timeout_ms(); - - let preferred_user_selectors = [ - "input[type=email]", - "input[name=email]", - "input[id=email]", - "input[autocomplete=email]", - "input[autocomplete=username]", - "input[name=username]", - "input[name*=email i]", - "input[name*=user i]", - "input[id*=email i]", - "input[id*=user i]", - "input[type=text][name*=email i]", - "input[type=text][name*=user i]", - "input[type=text][id*=email i]", - "input[type=text][id*=user i]", - "input[type=text][autocomplete=email]", - "input[type=text][autocomplete=username]", - ]; - let fallback_user_selectors = ["input[type=text]", "input:not([type])"]; - let auto_submit_selectors = [ - "button[type=submit]", - "input[type=submit]", - "button:not([type])", - ]; - - let username_sel = cmd - .get("usernameSelector") - .and_then(|v| v.as_str()) - .map(String::from) - .or(cred.username_selector); - let password_sel = cmd - .get("passwordSelector") - .and_then(|v| v.as_str()) - .map(String::from) - .or(cred.password_selector); - let submit_sel = cmd - .get("submitSelector") - .and_then(|v| v.as_str()) - .map(String::from) - .or(cred.submit_selector); - - // Find and fill username - let user_sel = if let Some(s) = username_sel { - wait_for_selector(&mgr.client, &session_id, &s, "visible", auth_timeout_ms) - .await - .map_err(|_| format!("Timed out waiting for username selector '{}'", s))?; - s - } else { - let preferred_window_ms = auth_timeout_ms.min(AUTH_LOGIN_PREFERRED_SELECTOR_WINDOW_MS); - let fallback_window_ms = auth_timeout_ms.saturating_sub(preferred_window_ms); - - match wait_for_any_selector( - &mgr.client, - &session_id, - &preferred_user_selectors, - preferred_window_ms, - ) - .await - { - Ok(selector) => selector, - Err(_) => { - if fallback_window_ms == 0 { - return Err(format!( - "Timed out waiting for username field (preferred selectors for {}ms: {})", - preferred_window_ms, - preferred_user_selectors.join(", ") - )); - } - - wait_for_any_selector( - &mgr.client, - &session_id, - &fallback_user_selectors, - fallback_window_ms, - ) - .await - .map_err(|_| { - format!( - "Timed out waiting for username field (preferred selectors for {}ms: {}; fallback selectors for {}ms: {})", - preferred_window_ms, - preferred_user_selectors.join(", "), - fallback_window_ms, - fallback_user_selectors.join(", ") - ) - })? - } - } - }; - interaction::fill( - &mgr.client, - &session_id, - &state.ref_map, - &user_sel, - &username, - &state.iframe_sessions, - ) - .await?; - - // Find and fill password - let pass_sel = password_sel.unwrap_or_else(|| "input[type=password]".to_string()); - wait_for_selector( - &mgr.client, - &session_id, - &pass_sel, - "visible", - auth_timeout_ms, - ) - .await - .map_err(|_| format!("Timed out waiting for password selector '{}'", pass_sel))?; - interaction::fill( - &mgr.client, - &session_id, - &state.ref_map, - &pass_sel, - &password, - &state.iframe_sessions, - ) - .await?; - - // Find and click submit - let sub_sel = if let Some(s) = submit_sel { - wait_for_selector(&mgr.client, &session_id, &s, "visible", auth_timeout_ms) - .await - .map_err(|_| format!("Timed out waiting for submit selector '{}'", s))?; - s - } else { - wait_for_any_selector( - &mgr.client, - &session_id, - &auto_submit_selectors, - auth_timeout_ms, - ) - .await - .map_err(|_| { - format!( - "Timed out waiting for submit button (tried selectors: {})", - auto_submit_selectors.join(", ") - ) - })? - }; - interaction::click( - &mgr.client, - &session_id, - &state.ref_map, - &sub_sel, - "left", - 1, - &state.iframe_sessions, - ) - .await?; - - // Wait for navigation after submit (with fallback timeout) - let mut rx = mgr.client.subscribe(); - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); - let mut navigated = false; - - loop { - let result = tokio::time::timeout_at(deadline, rx.recv()).await; - match result { - Ok(Ok(event)) => { - if event.session_id.as_deref() == Some(&session_id) { - match event.method.as_str() { - "Page.frameNavigated" | "Page.loadEventFired" => { - navigated = true; - break; - } - _ => {} - } - } - } - Ok(Err(_)) => break, - Err(_) => break, - } - } - - if !navigated { - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - } - - Ok(json!({ "loggedIn": true, "name": name })) -} - // --------------------------------------------------------------------------- // Confirmation handlers (stub) // --------------------------------------------------------------------------- @@ -7438,183 +6878,6 @@ mod tests { )) } - #[tokio::test] - async fn test_stream_enable_disable_and_status_without_browser() { - let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); - let socket_dir = unique_socket_dir("stream-runtime"); - fs::create_dir_all(&socket_dir).expect("socket dir should be created"); - guard.set( - "AGENT_BROWSER_SOCKET_DIR", - socket_dir.to_str().expect("socket dir should be utf-8"), - ); - guard.set("AGENT_BROWSER_SESSION", "stream-runtime-session"); - - let mut state = DaemonState::new(); - - let disabled_status = handle_stream_status(&state) - .await - .expect("status should work before enable"); - assert_eq!(disabled_status["enabled"], false); - assert_eq!(disabled_status["port"], Value::Null); - assert_eq!(disabled_status["connected"], false); - assert_eq!(disabled_status["screencasting"], false); - - let enabled_status = handle_stream_enable(&json!({ "port": 0 }), &mut state) - .await - .expect("stream enable should succeed"); - let port = enabled_status["port"] - .as_u64() - .expect("runtime stream should report a bound port"); - assert!(port > 0, "runtime stream should bind a non-zero port"); - assert_eq!(enabled_status["enabled"], true); - assert_eq!(enabled_status["connected"], false); - assert_eq!(enabled_status["screencasting"], false); - - let stream_path = socket_dir.join("stream-runtime-session.stream"); - let port_file = - fs::read_to_string(&stream_path).expect("stream metadata file should exist"); - assert_eq!(port_file.trim(), port.to_string()); - - let duplicate_err = handle_stream_enable(&json!({}), &mut state) - .await - .expect_err("duplicate enable should fail"); - assert!(duplicate_err.contains("already enabled")); - - let status = handle_stream_status(&state) - .await - .expect("status should work after enable"); - assert_eq!(status["enabled"], true); - assert_eq!(status["port"], port); - - let disabled = handle_stream_disable(&mut state) - .await - .expect("stream disable should succeed"); - assert_eq!(disabled["disabled"], true); - assert!( - !stream_path.exists(), - "disabling runtime stream should remove the metadata file" - ); - assert!(state.stream_server.is_none()); - assert!(state.stream_client.is_none()); - - let final_status = handle_stream_status(&state) - .await - .expect("status should work after disable"); - assert_eq!(final_status["enabled"], false); - assert_eq!(final_status["port"], Value::Null); - - let disable_err = handle_stream_disable(&mut state) - .await - .expect_err("duplicate disable should fail"); - assert!(disable_err.contains("not enabled")); - - let _ = fs::remove_dir_all(&socket_dir); - } - - #[tokio::test] - async fn test_stream_disable_preserves_existing_screencast_state() { - let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); - let socket_dir = unique_socket_dir("stream-preserve-screencast"); - fs::create_dir_all(&socket_dir).expect("socket dir should be created"); - guard.set( - "AGENT_BROWSER_SOCKET_DIR", - socket_dir.to_str().expect("socket dir should be utf-8"), - ); - guard.set( - "AGENT_BROWSER_SESSION", - "stream-preserve-screencast-session", - ); - - let mut state = DaemonState::new(); - handle_stream_enable(&json!({ "port": 0 }), &mut state) - .await - .expect("stream enable should succeed"); - state.screencasting = true; - - let disabled = handle_stream_disable(&mut state) - .await - .expect("stream disable should succeed"); - assert_eq!(disabled["disabled"], true); - assert!( - state.screencasting, - "stream disable should not clear an independently managed screencast state" - ); - - let _ = fs::remove_dir_all(&socket_dir); - } - - #[tokio::test] - async fn test_stream_disable_clears_state_when_stream_file_removal_fails() { - let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); - let socket_dir = unique_socket_dir("stream-disable-cleanup"); - fs::create_dir_all(&socket_dir).expect("socket dir should be created"); - guard.set( - "AGENT_BROWSER_SOCKET_DIR", - socket_dir.to_str().expect("socket dir should be utf-8"), - ); - guard.set("AGENT_BROWSER_SESSION", "stream-disable-cleanup-session"); - - let mut state = DaemonState::new(); - handle_stream_enable(&json!({ "port": 0 }), &mut state) - .await - .expect("stream enable should succeed"); - - let stream_path = socket_dir.join("stream-disable-cleanup-session.stream"); - fs::remove_file(&stream_path).expect("stream metadata file should exist"); - fs::create_dir(&stream_path).expect("directory should force remove_stream_file failure"); - - let err = handle_stream_disable(&mut state) - .await - .expect_err("stream disable should surface file removal failure"); - assert!(err.contains("Failed to remove stream metadata")); - assert!( - state.stream_server.is_none(), - "stream disable should clear stream_server even when metadata cleanup fails" - ); - assert!( - state.stream_client.is_none(), - "stream disable should clear stream_client even when metadata cleanup fails" - ); - - let _ = fs::remove_dir_all(&socket_dir); - } - - #[tokio::test] - async fn test_stream_enable_port_conflict_returns_error() { - let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); - let socket_dir = unique_socket_dir("stream-port-conflict"); - fs::create_dir_all(&socket_dir).expect("socket dir should be created"); - guard.set( - "AGENT_BROWSER_SOCKET_DIR", - socket_dir.to_str().expect("socket dir should be utf-8"), - ); - guard.set("AGENT_BROWSER_SESSION", "stream-port-conflict-session"); - - let listener = std::net::TcpListener::bind("127.0.0.1:0") - .expect("test should reserve an ephemeral port"); - let port = listener - .local_addr() - .expect("listener should have local addr") - .port(); - - let mut state = DaemonState::new(); - let err = handle_stream_enable(&json!({ "port": port }), &mut state) - .await - .expect_err("conflicting port should fail"); - assert!(err.contains("Failed to bind stream server")); - assert!(state.stream_server.is_none()); - assert!(state.stream_client.is_none()); - assert!( - !socket_dir - .join("stream-port-conflict-session.stream") - .exists(), - "failed enable should not leave stale metadata behind" - ); - - drop(listener); - let _ = fs::remove_dir_all(&socket_dir); - } - #[test] fn test_success_response_structure() { let resp = success_response("cmd-1", json!({"url": "https://example.com"})); @@ -8058,55 +7321,6 @@ mod tests { assert_eq!(result["success"], false); } - #[tokio::test] - #[allow(clippy::await_holding_lock)] - 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!({ - "action": "credentials_set", - "name": "test-cred-action", - "username": "user", - "password": "pass", - "id": "c1" - }); - let result = execute_command(&set_cmd, &mut state).await; - assert_eq!(result["success"], true); - - let get_cmd = json!({ - "action": "credentials_get", - "name": "test-cred-action", - "id": "c2" - }); - let result = execute_command(&get_cmd, &mut state).await; - assert_eq!(result["success"], true); - assert_eq!(result["data"]["username"], "user"); - - let list_cmd = json!({ "action": "credentials_list", "id": "c3" }); - let result = execute_command(&list_cmd, &mut state).await; - assert_eq!(result["success"], true); - - let del_cmd = json!({ - "action": "credentials_delete", - "name": "test-cred-action", - "id": "c4" - }); - 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] async fn test_state_list_via_actions() { let mut state = DaemonState::new(); diff --git a/cli/src/native/mod.rs b/cli/src/native/mod.rs index f86c816e8..e24a98cf0 100644 --- a/cli/src/native/mod.rs +++ b/cli/src/native/mod.rs @@ -15,8 +15,6 @@ pub mod diff; #[allow(dead_code)] pub mod element; #[allow(dead_code)] -pub mod inspect_server; -#[allow(dead_code)] pub mod interaction; #[allow(dead_code)] pub mod network; diff --git a/cli/src/native/parity_tests.rs b/cli/src/native/parity_tests.rs index 2c0684048..56dbe607a 100644 --- a/cli/src/native/parity_tests.rs +++ b/cli/src/native/parity_tests.rs @@ -48,7 +48,6 @@ const DOCUMENTED_ACTIONS: &[&str] = &[ "url", "title", "content", - "evaluate", "close", "snapshot", "screenshot", @@ -107,10 +106,6 @@ const DOCUMENTED_ACTIONS: &[&str] = &[ "download", "diff_snapshot", "diff_url", - "credentials_set", - "credentials_get", - "credentials_delete", - "credentials_list", "mouse", "keyboard", "focus", @@ -137,7 +132,6 @@ const DOCUMENTED_ACTIONS: &[&str] = &[ "addscript", "addinitscript", "addstyle", - "clipboard", "wheel", "device", "screencast_start", @@ -173,12 +167,6 @@ const DOCUMENTED_ACTIONS: &[&str] = &[ "unroute", "requests", "request_detail", - "credentials", - "auth_save", - "auth_login", - "auth_list", - "auth_delete", - "auth_show", "confirm", "deny", "swipe", @@ -202,7 +190,7 @@ fn minimal_command(action: &str, id: &str) -> Value { "navigate" | "diff_url" | "waitforurl" => { obj.insert("url".to_string(), json!("https://example.com")); } - "evaluate" | "expose" => { + "expose" => { obj.insert("script".to_string(), json!("1")); } "click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select" @@ -440,65 +428,10 @@ async fn test_state_list_without_browser() { assert!(result["data"]["files"].is_array()); } -#[tokio::test] -async fn test_credentials_list_without_browser() { - let mut state = DaemonState::new(); - let cmd = json!({ "action": "credentials_list", "id": "nb-2" }); - let result = execute_command(&cmd, &mut state).await; - - assert_eq!(result["success"], true); - assert!(result["data"]["credentials"].is_array() || result["data"]["profiles"].is_array()); -} - // --------------------------------------------------------------------------- // 4. New feature parity tests // --------------------------------------------------------------------------- -#[tokio::test] -async fn test_auth_profile_name_validation() { - use super::auth; - let _key_guard = TestKeyGuard::new(); - let valid = auth::credentials_set("valid-name_123", "u", "p", None); - assert!(valid.is_ok()); - let invalid = auth::credentials_set("invalid/name", "u", "p", None); - assert!(invalid.is_err()); - let invalid2 = auth::credentials_set("", "u", "p", None); - assert!(invalid2.is_err()); - let invalid3 = auth::credentials_set("has space", "u", "p", None); - assert!(invalid3.is_err()); - // Cleanup - let _ = auth::credentials_delete("valid-name_123"); -} - -#[tokio::test] -async fn test_auth_save_and_show() { - use super::auth; - let _key_guard = TestKeyGuard::new(); - let result = auth::auth_save( - "parity-roundtrip", - "https://example.com", - "user", - "pass", - Some("input#user"), - None, - None, - ); - assert!(result.is_ok()); - - let show = auth::auth_show("parity-roundtrip"); - assert!(show.is_ok()); - let data = show.unwrap(); - assert_eq!(data["profile"]["username"], "user"); - assert_eq!(data["profile"]["usernameSelector"], "input#user"); - - let full = auth::credentials_get_full("parity-roundtrip"); - assert!(full.is_ok()); - assert_eq!(full.unwrap().password, "pass"); - - // Cleanup - let _ = auth::credentials_delete("parity-roundtrip"); -} - #[tokio::test] async fn test_har_start_stop_without_browser() { let mut state = DaemonState::new(); diff --git a/cli/src/output.rs b/cli/src/output.rs index d49aa9723..1aef5edce 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1560,37 +1560,6 @@ Examples: "## } - // === Eval === - "eval" => { - r##" -agent-browser eval - Execute JavaScript - -Usage: agent-browser eval [options]