Compare commits

..
263 Commits
Author SHA1 Message Date
Chris Tate 0a36666587 agentcore skill (#1122) 2026-04-02 21:00:15 -05:00
Chris Tate 2a44b515ee v0.24.0 (#1121) 2026-04-02 20:42:07 -05:00
Chris Tate 13ed01b3bd agentcore docs (#1120)
* agentcore docs

* fixes

* fixes
2026-04-02 20:31:14 -05:00
Pahud HsiehandChris Tate 8561a755ef feat: add AWS Bedrock AgentCore browser provider (native Rust) (#397)
* feat: add AWS Bedrock AgentCore browser provider (native Rust)

- Add agentcore provider with SigV4 authentication
- AWS SDK deps are optional behind 'agentcore' feature flag
- Build with: cargo build --features agentcore
- Supports AGENTCORE_REGION, AGENTCORE_PROFILE_ID, AGENTCORE_BROWSER_ID env vars
- Returns session ID and Live View URL in launch response
- Add connect_cdp_with_headers for signed WebSocket connections

* test: add unit tests for AgentCore provider

* refactor: use lightweight manual SigV4 signing instead of AWS SDK

- Replace aws-sigv4/aws-config with manual HMAC-SHA256 signing
- Removes ~60s compile time and significant binary size
- Credentials read from AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars
- Supports AWS_SESSION_TOKEN for temporary credentials

* fix: correct AgentCore API endpoints

- Host: bedrock-agentcore.{region}.amazonaws.com
- Start session: PUT /browsers/{id}/sessions/start
- Stop session: PUT /browsers/{id}/sessions/stop
- Add urlencoding for browser ID in path
- Add AWS_DEFAULT_REGION fallback

* fix: use profileConfiguration.profileIdentifier for AgentCore profile

The AWS Bedrock AgentCore API expects profile configuration in the format:
{
  "profileConfiguration": {
    "profileIdentifier": "<profile-id>"
  }
}

Not the flat "profileId" field that was previously used.

* feat: support AWS credential provider chain via AWS CLI

- Try env vars first (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
- Fall back to 'aws configure export-credentials --format env'
- Honor AWS_PROFILE environment variable
- Works with SSO, IAM roles, credential files, etc.

---------

Co-authored-by: Chris Tate <chris@ctate.dev>
2026-04-02 18:33:37 -05:00
Chris Tate 89595836c6 v0.23.4 (#1100) 2026-03-31 02:09:57 -05:00
Chris Tate 7b4b124e7f Fix daemon hang on Linux caused by waitpid(-1) race condition (#1098)
* Fix daemon hang on Linux caused by waitpid(-1) race condition

Fixes #1035

The SIGCHLD handler added in v0.22.3 called `waitpid(-1, WNOHANG)` to reap zombie Chrome processes. This races with Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)` reaps *any* child in the process, stealing the exit status before the `Child` handle can collect it. The result is `ECHILD` errors in `BrowserManager::has_process_exited()` and `ChromeProcess::kill()`, leaving the daemon in a broken state that manifests as indefinite hangs on Linux servers.

The fix removes the global SIGCHLD handler and `reap_children()` function entirely. Instead, the existing 500ms drain interval now checks `mgr.has_process_exited()` (which delegates to `Child::try_wait()`) for targeted, race-free crash detection. When Chrome is detected as crashed, the `BrowserManager` is closed and daemon state is reset.

## Changes

- Removed `SIGCHLD` signal handler and `reap_children()` from the Unix daemon event loop
- Enhanced the drain interval to detect Chrome crashes via `has_process_exited()` and clean up state
- Added 3 regression tests:
  - Static source scan that fails if `waitpid(-1)` is re-introduced in production code
  - `try_wait()` correctness test for exit detection without a SIGCHLD handler
  - Kill detection test simulating a Chrome crash

* fmt
2026-03-31 01:59:07 -05:00
Chris Tate b2b6356d63 fix release notes (#1097)
* fix release notes

* contributors note
2026-03-30 20:54:59 -05:00
Chris Tate e6ba1eb8c9 v0.23.3 (#1096) 2026-03-30 20:29:20 -05:00
1f4b6b9d7a fix: include buttons bitmask in drag mouseMoved events (#1087)
* fix: include buttons bitmask in drag mouseMoved events

The drag handler was omitting the `buttons` field from every
`mouseMoved` event dispatched during the move phase.  Without it the
browser sees `event.buttons === 0`, meaning no button is held, so
`dragstart`/`dragover`/`drop` never fire and the drop target never
receives the element.

Fix:
- Add `"buttons": 1` (left-button mask) to each `mouseMoved` sent
  while the button is held.
- Add `"buttons": 1` to `mousePressed` and `"buttons": 0` to
  `mouseReleased`, consistent with how `dispatch_click` handles the
  same fields in interaction.rs.
- Correct the parity-test fixture for `drag`, which was supplying a
  `selector` key instead of the `source` key that `handle_drag` reads.
- Add an e2e test (`e2e_drag_action_sends_buttons_during_move`) that
  drives the high-level `drag` action against the existing
  `html5_drag_probe` fixture and asserts that `mousemove` events carry
  `buttons == 1` and that `dragstart` fires on the source element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: fix rustfmt formatting in e2e drag test

---------

Co-authored-by: wangjingjing <wangjingjing.99@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-30 20:21:04 -05:00
Chris Tate 6c93480d0d streamline release (#1095)
* update release

* more docs

* dates
2026-03-30 19:53:53 -05:00
github-actions[bot] cc9da7aff7 chore: version packages (#1094)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-30 19:14:43 -05:00
Chris Tate 3c942e2874 prepare v0.23.2 (#1093) 2026-03-30 18:38:57 -05:00
Chris Tate 40fdb4284d feat: dashboard provider support and session creation improvements (#1092)
Add provider icons and session creation from the dashboard UI.
Sessions can now be created with cloud providers (Browserbase,
Browserless, Browser Use, Kernel) in addition to local engines.

CLI changes:
- Track provider via .provider files alongside .engine files
- Add WaitUntil::None variant to skip lifecycle event waits for providers
- Auto-set waitUntil=none when --provider is used with navigate
- Fix Browser Use: use direct WSS connection (wss://connect.browser-use.com)
- Add connect_cdp_direct for providers with page-level CDP proxies
- Fix resolve_cdp_url to convert https:// provider URLs to wss://
- Treat empty CDP session_id as None (omit from protocol messages)
- Fix Browserbase: send explicit JSON body + Content-Type header
- Increase CDP connect timeout to 25s for remote providers
- Clean up .provider files on session close

Dashboard changes:
- Show provider or engine icon per session in sidebar
- New session dialog with unified engine/provider selector grid
- Async session creation with loading state and error display
- Kill zombie daemons on provider connection failure
- Parse CLI JSON error output for user-friendly messages
- Default new session URL to https://agent-browser.dev
2026-03-30 18:35:12 -05:00
github-actions[bot] f8bc8b368a chore: version packages (#1090)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-30 13:32:26 -05:00
Chris Tate fbcab375b0 chore: prepare v0.23.1 release (#1089)
* chore: add patch changeset for v0.23.1 release

Add changeset covering 7 commits since v0.23.0: auto-dialog dismissal,
Puppeteer cache fallback, console output improvements, same-document
navigation fix, cross-domain save_state, external tab detection in CDP
mode, and dashboard hot-reload.

Fill documentation gaps: Puppeteer/Brave in browser discovery tables,
console --json args field, AGENT_BROWSER_NO_AUTO_DIALOG env var in
SKILL.md.

* chore: point package.json homepage to agent-browser.dev
2026-03-30 13:12:07 -05:00
Chris Tate 8d78fcbbb3 fix: Windows Chrome extraction and debugging environment (#1088)
* windows debugging

* fixes

* fixes

* fix: handle Windows path separators in Chrome zip extraction

The zip crate's enclosed_name() normalizes paths to use backslashes on
Windows, but extract_zip used split_once('/') which only matches forward
slashes. This caused Chrome to be extracted into a nested chrome-win64/
subdirectory instead of directly into the version directory.

Also adds debug diagnostics to find_installed_chrome() (gated behind
AGENT_BROWSER_DEBUG) and better error messages when Chrome cache exists
but no binary is found.

Fixes #1076

* feat: add Puppeteer browser cache as Chrome fallback

Search ~/.cache/puppeteer/chrome/ (or PUPPETEER_CACHE_DIR) for Chrome
binaries before falling back to Playwright's cache. Puppeteer v19+
stores Chrome for Testing in this location, so users with an existing
Puppeteer install can use agent-browser without a separate install step.

* fmt
2026-03-30 12:37:01 -05:00
hechang27-sprtandClaude Opus 4.6 312db04e5e fix: skip wait_for_lifecycle on same-document navigation (#1059)
Chrome returns loader_id: None for same-document navigations (e.g., hash
routing in SPAs). In these cases, Page.loadEventFired never fires, causing
wait_for_lifecycle to hang forever.

The fix checks nav_result.loader_id.is_some() before waiting for lifecycle
events. Also added regression test e2e_navigate_same_url_twice_should_not_hang.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 12:25:37 -06:00
jin.2andhyunjinee 369f48752a fix: expose raw CDP args in console output and use preview for formatting (#1040)
Closes #1039

- Add `preview` field to `RemoteObject` to capture CDP object previews
- Implement `format_console_arg` using preview data (value → preview → description)
- Store raw CDP args in `ConsoleEntry` and include in JSON output
- Skip typed `ConsoleApiCalledEvent` deserialization in favor of direct param extraction
- Unify console arg formatting between daemon (actions.rs) and stream (stream.rs)

Before: `console.log({userId: "abc", count: 42})` → `"Object"`
After:  `console.log({userId: "abc", count: 42})` → `{userId: "abc", count: 42}`

JSON output now includes raw `args` array for programmatic access by AI agents.

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-29 12:24:27 -06:00
Chris Tateandctate 6dd53449e8 Add auto-dismissal for alert and beforeunload dialogs (#1075)
* Add auto-dismissal for alert and beforeunload dialogs

This PR adds automatic handling of JavaScript dialogs to prevent the agent from blocking indefinitely when `alert()` or `beforeunload` dialogs appear on web pages.

## Summary

Previously, when a website displayed native browser confirmation dialogs (like alerts or "Are you sure you want to leave?" prompts), agent-browser would hang waiting for manual intervention. This is a common issue since many websites use these dialogs for notifications or navigation warnings.

## Changes Made

- **Auto-dismiss functionality**: Added a background task that automatically accepts `alert` and `beforeunload` dialogs while leaving `confirm` and `prompt` dialogs for explicit handling
- **New flag**: Added `--no-auto-dialog` flag to disable automatic handling when needed
- **Environment variable**: Added `AGENT_BROWSER_NO_AUTO_DIALOG` for configuration
- **Documentation**: Updated README and docs with usage examples and configuration details
- **Tests**: Added comprehensive test coverage for flag parsing and dialog handling logic

## Implementation Details

- Only `alert` (notification-only) and `beforeunload` (navigation warning) dialogs are auto-handled for safety
- `confirm` and `prompt` dialogs still require explicit `dialog accept/dismiss` commands to ensure agents make deliberate choices for destructive actions
- The feature is enabled by default since these dialog types rarely require user decision-making
- Uses Chrome DevTools Protocol's `Page.handleJavaScriptDialog` for reliable dialog dismissal

Fixes #1070

* Log dialog type and message before auto-dismissal

Without this, auto-dismissed alert/beforeunload dialogs are silently
swallowed and the agent has no way to see what the dialog said. Adding
an eprintln before the CDP call makes the dismissal visible in stderr
for debugging.

* Log dialog dismissal errors instead of silently discarding them

- Remove premature "accepted" from log message since it fires before
  the CDP command executes
- Replace `let _ =` with `if let Err(e)` to log failures when
  Page.handleJavaScriptDialog fails
- Apply rustfmt to auto-dialog tests

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-29 12:00:27 -06:00
Chris Tateandctate da7fef3fef fix: dashboard server picks up installed files without restart (#1066)
The dashboard HTTP server checked for index.html once at startup and
cached the result. If the server started before `dashboard install`,
it permanently served the "not installed" fallback page.

Check for installed dashboard files on each request instead, so
`dashboard install` takes effect immediately on a running server.

Fixes #1065

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-29 11:54:06 -06:00
jin.2andhyunjinee 43d9c40bc4 fix: detect externally opened tabs in --cdp mode (#1042)
* fix: detect externally opened tabs in --cdp mode (#1037)

Tabs opened outside of agent-browser (e.g. by the user or another CDP
client) were invisible to `tab list` because:

1. `Target.targetCreated` with chrome://newtab/ was filtered by
   `is_internal_chrome_target`, and the subsequent `targetInfoChanged`
   with the real URL could not update a target that was never tracked.

2. The background drain loop only ran when `request_tracking ||
   har_recording` was active, so target events between commands were
   silently dropped from the broadcast channel.

Fix: promote untracked targets in `targetInfoChanged` to new targets,
run the background drain unconditionally (guarded by browser presence),
and extract `apply_drained_events` to share target lifecycle processing
(attach, domain filter, iframe sessions) between execute_command and
the background drain.

* refactor: clean up HashSet import and remove call-site duplication

- Import HashSet alongside HashMap instead of using fully-qualified path
- Replace duplicated drain+apply sequence in execute_command with
  drain_cdp_events_background call

* style: apply cargo fmt

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-29 11:51:49 -06:00
jin.2andhyunjinee dc26ff7667 fix: save_state captures cross-domain cookies and localStorage (#1064)
The Rust rewrite of save_state only captured cookies and localStorage
for the current page's origin, silently dropping cross-domain data
(e.g. SSO/CAS auth cookies). This was a regression from the JS version.

Cookies: replace Network.getCookies with Network.getAllCookies to
return cookies from all domains the browser has visited.

localStorage: track visited origins in BrowserManager during navigation,
then collect their localStorage via a temporary CDP target with Fetch
interception (serves blank HTML to avoid real network requests).

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-28 13:41:53 -07:00
github-actions[bot] 747a3772e1 chore: version packages (#1054)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-27 10:30:33 -07:00
Chris Tate bbad2de627 fix: include root package in pnpm workspace for changesets (#1053)
The dashboard PR introduced pnpm-workspace.yaml but only listed
packages/* and docs. Changesets could no longer find the root
agent-browser package, breaking the release CI. Adding '.' makes
the root a workspace package again.
2026-03-27 10:17:48 -07:00
Chris Tate 0f0f300d40 chore: add minor changeset for v0.23.0 release (#1052) 2026-03-27 09:46:17 -07:00
Chris Tate db215a1467 fix lightpanda (#1050)
* fix lightpanda

* fmt
2026-03-27 09:33:08 -07:00
Chris Tateandctate a95bc0f75a fix(windows): fall back to OS-assigned port when Hyper-V blocks daemon TCP bind (#1041)
On Windows the daemon derives a TCP port from the session name via a
djb2 hash (e.g. "default" → 50838). On many machines this port falls
inside Hyper-V's excluded port range (winnat), causing EACCES on bind
and preventing the daemon from starting.

Changes:
- daemon: try the hash-derived port first; on failure, bind to port 0
  (OS-assigned) and write the actual port to the .port file
- client (connection.rs, stream.rs): read the .port file to discover the
  daemon's actual port, falling back to the hash if the file is absent
- run_daemon: guard .sock file operations with #[cfg(unix)] and add
  .port file cleanup for #[cfg(windows)]

Fixes #390

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-26 21:01:06 -07:00
Chris Tate 995a47fdb0 fix: use TCP instead of Unix socket on Windows in dashboard relay (#1038)
`relay_command_to_daemon` in stream.rs used `tokio::net::UnixStream`
unconditionally, which doesn't compile on Windows. Add platform-
conditional code matching the existing pattern in daemon.rs and
connection.rs: Unix sockets on unix, TCP on Windows.
2026-03-26 13:36:22 -07:00
f9174513c2 dashboard (#1034)
* dashboard

* fix: re-apply download behavior on recording context (#1019)

* fix: re-apply download behavior on recording context

record start creates a new browser context via Target.createBrowserContext.
Browser.setDownloadBehavior called at launch only applies to the default
context, so downloads in the recording context are silently dropped.

Fix:
1. Store download_path on BrowserManager (from LaunchOptions)
2. After creating the recording context, call Browser.setDownloadBehavior
   with the new browserContextId

This ensures downloads work during recording.

Fixes #1018

* fix: add download_path to third BrowserManager constructor (auto_connect_cdp)

* fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023)

When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon
now:

1. Reaps the zombie immediately via a SIGCHLD handler in the event loop
   that calls waitpid(-1, WNOHANG)
2. Detects the crash instantly on the next command via a non-blocking
   try_wait() check (has_process_exited), avoiding the 3-second CDP
   timeout that is_connection_alive() would incur
3. Auto-relaunches Chrome transparently for the caller

Fixes #1017

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>

* fix: route keyboard type through text input (#1014)

* fix: handle --clear flag in console command (#1015)

The console and errors commands parsed --clear from CLI args but the
action handlers silently ignored the flag. The handlers did not accept
the cmd parameter so they had no way to read the clear field.

Changes:
- Add clear_console() method to EventTracker in network.rs
- Update handle_console to accept cmd, read the clear field, and clear
  the buffer when --clear is passed (returns {cleared: true})
- Update call site in execute_command to pass cmd

Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com>

* chore: patch release - ### Bug Fixes

- **Re-apply download behavior on r... (#1025)

* Add runtime stream enable/disable/status commands (#951)

* Add runtime stream management commands

* Run rustfmt and satisfy clippy

* Fix stream disable cleanup semantics

* Format stream disable regression tests

* fix: retain radio/checkbox elements in compact snapshot tree (#1008)

compact_tree() checked for "[ref=" to identify lines worth keeping, but
radio and checkbox elements render as e.g. [checked=false, ref=e1] where
the "[" opens before "checked=", not "ref=". Dropping the leading bracket
so the check is just "ref=" fixes the match for all elements with refs.

Fixes #1006

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>

* chore: version packages (#1027)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fixes

* dashboard

* fixes

* remove observe

* fmt

* fixes

* fixes

* jotai

* fmt

* upload dashboard

---------

Co-authored-by: Stefan Smiljkovic <stefan@vanila.io>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Co-authored-by: zhanba <c5e1856@gmail.com>
Co-authored-by: xuyongliang <478439790@qq.com>
Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com>
Co-authored-by: Thomas Kosiewski <thoma471@googlemail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-26 08:43:35 -07:00
github-actions[bot] 63f03b8e06 chore: version packages (#1029)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-25 18:24:47 -07:00
github-actions[bot] 699a461646 chore: version packages (#1027)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-25 11:48:46 -07:00
Chris Tateandctate 89a8ceccf7 fix: retain radio/checkbox elements in compact snapshot tree (#1008)
compact_tree() checked for "[ref=" to identify lines worth keeping, but
radio and checkbox elements render as e.g. [checked=false, ref=e1] where
the "[" opens before "checked=", not "ref=". Dropping the leading bracket
so the check is just "ref=" fixes the match for all elements with refs.

Fixes #1006

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-25 11:40:19 -07:00
Thomas Kosiewski 67b5ee1600 Add runtime stream enable/disable/status commands (#951)
* Add runtime stream management commands

* Run rustfmt and satisfy clippy

* Fix stream disable cleanup semantics

* Format stream disable regression tests
2026-03-25 11:36:16 -07:00
Chris Tate eb64ca497a chore: patch release - ### Bug Fixes
- **Re-apply download behavior on r... (#1025)
2026-03-25 11:29:51 -07:00
xuyongliangandxuyongliang 8c6fc35450 fix: handle --clear flag in console command (#1015)
The console and errors commands parsed --clear from CLI args but the
action handlers silently ignored the flag. The handlers did not accept
the cmd parameter so they had no way to read the clear field.

Changes:
- Add clear_console() method to EventTracker in network.rs
- Update handle_console to accept cmd, read the clear field, and clear
  the buffer when --clear is passed (returns {cleared: true})
- Update call site in execute_command to pass cmd

Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com>
2026-03-25 08:27:28 -07:00
zhanba 7d2cd726ec fix: route keyboard type through text input (#1014) 2026-03-25 08:05:20 -07:00
Chris Tateandctate 5ac01fa743 fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023)
When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon
now:

1. Reaps the zombie immediately via a SIGCHLD handler in the event loop
   that calls waitpid(-1, WNOHANG)
2. Detects the crash instantly on the next command via a non-blocking
   try_wait() check (has_process_exited), avoiding the 3-second CDP
   timeout that is_connection_alive() would incur
3. Auto-relaunches Chrome transparently for the caller

Fixes #1017

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-25 08:04:43 -07:00
Stefan Smiljkovic 2fb766fc78 fix: re-apply download behavior on recording context (#1019)
* fix: re-apply download behavior on recording context

record start creates a new browser context via Target.createBrowserContext.
Browser.setDownloadBehavior called at launch only applies to the default
context, so downloads in the recording context are silently dropped.

Fix:
1. Store download_path on BrowserManager (from LaunchOptions)
2. After creating the recording context, call Browser.setDownloadBehavior
   with the new browserContextId

This ensures downloads work during recording.

Fixes #1018

* fix: add download_path to third BrowserManager constructor (auto_connect_cdp)
2026-03-25 07:52:58 -07:00
Chris Tateandgithub-actions[bot] 0865851293 chore: version packages (#1009)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-24 15:21:31 -07:00
Chris Tate a0981979ca chore: patch release - ### New Features
- **Dialog status command** - Ad... (#1005)
2026-03-24 15:03:10 -05:00
Chris Tateandctate cd1f255129 fix: handle proxy authentication via CDP Fetch.authRequired (#1000)
* fix: handle proxy authentication via CDP Fetch.authRequired

Chrome's --proxy-server flag does not support credentials embedded in
the URL. When a proxy requires authentication, Chrome receives a 407
from the proxy but has no way to respond with credentials, resulting
in net::ERR_INVALID_AUTH_CREDENTIALS.

Fix by:
1. Parsing credentials from the proxy URL (already done by parse_proxy)
2. Storing them in DaemonState.proxy_credentials
3. Enabling Fetch.enable with handleAuthRequests: true
4. Responding to Fetch.authRequired events with Fetch.continueWithAuth
5. Passing only the server URL (without credentials) to --proxy-server
6. Forwarding credentials to the daemon via dedicated env vars

Also adds fallback to standard proxy env vars (HTTP_PROXY, HTTPS_PROXY,
ALL_PROXY, NO_PROXY) when AGENT_BROWSER_PROXY is not set.

Fixes #990

* refactor: use typed struct for parse_proxy, fix double Fetch.enable and username-only auth

- Replace serde_json::Value return from parse_proxy with a typed ParsedProxy struct
- Fix double Fetch.enable call when both proxy auth and domain filter are active
  (the second call could overwrite handleAuthRequests from the first)
- Allow username-only proxy auth (some proxies don't require a password)
- Handle empty username/password in parse_proxy as None instead of Some("")
- Use install_domain_filter_fetch in auto_launch for consistency
- Update unit tests to use typed struct fields

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-24 12:52:39 -05:00
Chris Tateandctate 23a117c5c2 fix: add font packages to install --with-deps for CJK and emoji support (#1002)
The `--with-deps` flag installed font rendering libraries (libfreetype6,
libfontconfig1) but no actual font files, causing CJK characters and
emoji to render as invisible/tofu on headless Linux systems.

Add font file packages for all three supported package managers:
- apt: fonts-noto-color-emoji, fonts-noto-cjk, fonts-freefont-ttf
- dnf: google-noto-cjk-fonts, google-noto-emoji-color-fonts, liberation-fonts
- yum: google-noto-cjk-fonts, liberation-fonts

Closes #1001

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-24 12:03:21 -05:00
Chris Tateandctate 32ffd8f3c4 feat: add dialog detection and document dialog commands (#999)
Fixes #992

When a JavaScript dialog (alert/confirm/prompt) blocks the page, agents
had no way to detect it — all commands just timed out with generic errors.

- Add `dialog status` command to check for pending dialogs
- Track dialog state via CDP Page.javascriptDialogOpening/Closed events
- Auto-inject `warning` field into all command responses when a dialog is
  pending, so agents can distinguish dialog-blocked timeouts from other issues
- Document dialog commands in SKILL.md (was missing entirely), README.md,
  docs site, and --help output

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-24 11:38:23 -05:00
Chris Tateandctate 780edb2c45 fix: download drops Browser-domain CDP events due to sessionId mismatch (#998)
Chrome's Browser.downloadWillBegin and Browser.downloadProgress events
may arrive without a sessionId or with a different sessionId than the
page session used to configure the download behavior. The previous code
required an exact session match, silently dropping these events and
causing the 30-second timeout -- which manifests as an endless download
loop when callers retry.

Changes:
- Accept Browser-domain download events regardless of sessionId while
  still matching Page-domain events by session to avoid cross-tab issues
- Add a brief retry loop (up to 1s) for the GUID file to appear on disk
  after Chrome signals completion, handling filesystem flush races
- Return a proper error instead of silently succeeding when the
  GUID-named file cannot be found
- Apply the same sessionId fix to handle_waitfordownload and add
  Browser.downloadProgress support (previously only checked
  Page.downloadProgress)

Fixes #989

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-24 11:35:49 -05:00
xuyongliangandxuyongliang 3150acd574 fix: console command returns only Done due to JSON field name mismatch (#986)
The get_console_json() method produced JSON with key 'entries' containing
objects with 'level' field, but the output formatter in output.rs expected
key 'messages' with 'type' field. This mismatch caused console output to
fall through all format checks and print only '[Done]'.

Changed get_console_json() to use 'messages' and 'type' to match the
output formatter expectations.

Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com>
2026-03-24 10:28:06 -05:00
b5b84051e4 fix: state show always fails with "Missing 'path' parameter" (#994)
Agent-Logs-Url: https://github.com/wanghanzhen/agent-browser/sessions/a0ee212c-9d4f-4b9c-906e-7a2d8fa8df4a

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wanghanzhen <25301012+wanghanzhen@users.noreply.github.com>
2026-03-24 10:19:46 -05:00
github-actions[bot] 859c8aaf94 chore: version packages (#987)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-24 07:56:53 -05:00
Chris Tate 3a3317b048 chore: patch release - ### Bug Fixes
- Fixed **modifier key chords** (e.... (#985)
2026-03-23 20:30:53 -05:00
Chris Tateandctate f806b666ba fix: preserve query parameters in --cdp HTTP URLs (#982)
When --cdp is given an HTTP/HTTPS URL (e.g. http://host:5095?mode=Hello),
resolve_cdp_url extracts host and port for CDP discovery but discards the
query string. The discovered WebSocket URL therefore never includes the
user's original query parameters, breaking relay servers that depend on
them.

Thread the original query string through discover_cdp_url and append it
to the final WebSocket URL after host/port rewriting. WebSocket URLs
(ws://, wss://) are already passed through unchanged and are unaffected.

Fixes #977

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-23 18:47:23 -05:00
Chris Tateandctate a7a59c94f3 Fix press Control+a and other modifier key chords (#980)
The `press` command was not parsing modifier+key chords (e.g.
`Control+a`, `Shift+Enter`). It sent the raw string as a single key
name, so CDP never applied the modifier — and the `text` field caused
the literal character to be inserted instead of triggering the shortcut.

Two changes:
1. `actions.rs` — add `parse_key_chord()` to split inputs like
   `Control+Shift+a` into the base key (`a`) and a CDP modifier
   bitmask (Alt=1, Ctrl=2, Meta=4, Shift=8), then call
   `press_key_with_modifiers` instead of `press_key`.
2. `interaction.rs` — suppress the `text` field in `keyDown`/`keyUp`
   events when Control or Meta modifiers are active, so the browser
   treats them as command chords rather than text input.

Includes unit tests for the chord parser covering plain keys, single
modifiers, multi-modifier combos, modifier aliases, and edge cases.

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-23 16:43:43 -05:00
github-actions[bot] ce1f1f5f81 chore: version packages (#975)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-23 12:21:00 -05:00
Chris Tate be30bc902d chore: add minor changeset for release (#973) 2026-03-23 12:07:07 -05:00
Chris Tateandctate 1391f00404 Fix download command to properly handle absolute paths and click elements (#970)
* Fix download command to properly handle absolute paths and click elements

The download command was not working correctly - it would return "done" but not actually download files to the specified path. The command was only setting download behavior without clicking the element or waiting for completion.

**Changes made:**
- Modified `handle_download` to take a `selector` parameter and click the download element
- Added proper absolute path resolution and directory creation
- Implemented CDP event listening to wait for download completion with 30s timeout
- Added file renaming logic to handle Chrome's GUID-based temporary filenames
- Changed response format to return the actual download path
- Fixed function signature to use `&mut DaemonState` for state modifications

**Implementation details:**
- Uses `Browser.downloadWillBegin` and `Browser.downloadProgress` CDP events to track downloads
- Falls back to finding the most recently modified file if GUID capture fails
- Creates parent directories automatically if they don't exist
- Handles both absolute and relative path inputs

Fixes #965

* Address review feedback: harden download path handling

- Canonicalize download directory to prevent path traversal attacks
- Remove dangerous fallback that renamed the most-recently-modified file
  in the directory (could silently rename unrelated files)
- Extract timeout to a named constant (DOWNLOAD_TIMEOUT)

* Fix download event loop: handle canceled state and Page.downloadWillBegin

- Detect "canceled" download state and return an error immediately instead
  of spinning until the 30s timeout.
- Also capture the download GUID from the deprecated Page.downloadWillBegin
  event for older Chrome compatibility, matching the existing
  Page.downloadProgress fallback.
- Consolidate duplicated session/event checks with a shared is_this_session
  variable and use match for cleaner state handling.

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-23 11:09:41 -05:00
Chris Tate c5020f2b89 Fix Enter key press not working by adding text field to keyDown events (#972)
This PR fixes an issue where pressing the Enter key via the `press Enter` command had no effect on web pages, even though the command executed successfully without errors.

## Problem
The Chrome DevTools Protocol `Input.dispatchKeyEvent` requires a `text` field in `keyDown` events for certain keys to trigger their default browser actions. Without this field, keys like Enter don't actually submit forms or perform their expected actions.

## Changes
- Added new `key_text()` function that returns the appropriate text value for special keys:
  - Enter returns `"\r"` (carriage return)
  - Tab returns `"\t"` (tab character)
  - Space returns `" "` (space character)
  - Single printable characters return themselves
  - Non-printable keys (Escape, Arrow keys, etc.) return `None`
- Modified `press_key_with_modifiers()` to populate both `text` and `unmodified_text` fields in the CDP keyDown event
- Added comprehensive unit tests for the new functionality

## Implementation Details
The fix ensures that when pressing Enter on a filled search box, the form actually submits as expected, matching the behavior of manually pressing the Enter key.

Fixes #966
2026-03-23 10:46:05 -05:00
Chris Tate 9b1961af93 fix: skip auto-connect when daemon already running to prevent multiple (#971)
Previously, the `--auto-connect` flag would send connection commands to the daemon on every CLI invocation, even when the daemon was already running and maintaining an active connection. This caused Chrome to repeatedly prompt for remote debugging permissions during long-running tasks.

This fix adds checks for `daemon_result.already_running` to skip sending launch commands when the daemon is already active and holding connections. The changes apply to:

- Auto-connect flow: Skip when daemon already running since it holds the connection from previous launch
- CDP connections: Skip sending commands but validate input eagerly for immediate error feedback
- Cloud provider connections: Skip when daemon already maintains the provider connection

This preserves the existing connection reuse logic in the daemon while preventing redundant connection attempts from the CLI side.

Fixes #962
2026-03-23 10:27:54 -05:00
ChunHao Chen ceaee00952 feat: add network request detail and filtering for request tracking (#935)
* feat: add network request detail and filtering for request tracking

- Add `network request <requestId>` command to view full request/response
  details including response body via CDP Network.getResponseBody
- Add --type, --method, --status filter flags to `network requests`
  - --type: comma-separated resource types (xhr,fetch,document)
  - --method: filter by HTTP method
  - --status: supports exact (200), class (2xx), range (400-499)
- Extend TrackedRequest with request_id, post_data, status,
  response_headers, mime_type fields
- Update Network.responseReceived handler to also populate
  tracked_requests (previously only updated HAR entries)
- Add tests for parse commands and matches_status_filter
- Update README, SKILL.md, docs, and help text

Closes #932

* fix: show request ID and status in network requests output
2026-03-23 10:17:17 -05:00
GyDi 5c5c0d8081 fix: enhance target tracking and update page information handling (#969) 2026-03-23 09:38:20 -05:00
Lppyand羲洋 b48c3e9dd2 feat: enhance snapshot usability by reducing AI cognitive load of semantic noise and -C flag (#968)
* fix: add ref for cursor-interactive content roles

* fix: format

* feat: always include cursor-interactive elements in snapshot, -C is deprecated

* feat: process StaticText aggregation and deduplication

* update test

* clean up

* fix: escape text of elements in snapshot

* fix: redundant slicing

* fix: cargo fmt

* feat: deduplicate redundant StaticText

---------

Co-authored-by: 羲洋 <lipengyang.lpy@alibaba-inc.com>
2026-03-23 09:35:43 -05:00
9c0955ca99 fix: prevent state commands from starting daemon without session_name (#677) (#964)
* fix: prevent state commands from starting daemon without session_name
   (#677)

  State management commands (state_list, state_show, state_clear,
  state_clean, state_rename) are pure file operations that don't need a
  running daemon. Previously, these commands would trigger daemon
  startup
  via ensure_daemon(), and if AGENT_BROWSER_SESSION_NAME was exported
  after the first command (e.g. `state clear --all`), the daemon would
  start without session_name. Subsequent open/close commands would
  reuse
  that daemon, causing close to skip state persistence entirely.

  Fix: execute state management commands locally in the CLI process
  before
  ensure_daemon() is called. This is done via a new
  dispatch_state_command() function in state.rs that centralizes the
  command routing, used by both the CLI (local path) and the daemon
  (batch/IPC path).

  Also:
  - Add OutputOptions::from_flags() helper to deduplicate construction
  - Add unit tests for dispatch_state_command routing and error
  handling

* style: fix fmt and clippy warnings

- Remove redundant closure in dispatch_state_command (clippy::redundant_closure)
- Remove needless borrow in run_batch (clippy::needless_borrow)
- Fix trailing blank lines (rustfmt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:32:14 -05:00
d374e413be chore: remove dead code and unused variables in actions.rs (#961)
* chore: remove dead code and unused variables in actions.rs

Remove `daemon_state_from_env` (superseded by `DaemonState::new`) and
`resolve_semantic_locator` (superseded by `handle_semantic_locator`),
both of which had zero call sites. Also remove unused `_session_id`
bindings in `handle_find` and `handle_multiselect`, and a no-op
`let _ = sid` in `handle_getbyrole`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: simplify `_sid` to `_` per review feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: use `is_ok()` instead of `if let Ok(_)` for idiomatic Rust

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:01:56 -05:00
Chris Tateandctate fb1e860b4e Improve upgrade command installation method detection robustness (#960)
* Improve upgrade command installation method detection robustness

The upgrade command was failing to detect installation method for users who installed via pnpm, yarn, or bun, showing "Could not detect installation method" errors.

## Changes Made

- **Added support for additional package managers**: Extended detection to include pnpm, yarn, and bun alongside existing npm, Homebrew, and Cargo support
- **Implemented install-time marker system**: Modified `postinstall.js` to write a `.install-method` marker file during installation, providing reliable detection that doesn't depend on fragile path heuristics
- **Enhanced path-based fallback detection**: Improved executable path analysis to better identify installation locations for all supported package managers
- **Added command probing**: Implemented fallback checks that query package managers directly to verify global installations

The detection now follows this robust hierarchy:
1. Read install-time marker file (most reliable)
2. Analyze executable path patterns
3. Probe package managers via subprocess calls

This ensures users can successfully upgrade regardless of their chosen package manager.

Fixes #954

* Add .install-method to .gitignore and note Yarn v2+ limitation

- Prevent bin/.install-method marker file from being accidentally
  committed during development
- Add comment clarifying that yarn global upgrade path only works
  with Yarn Classic (v1), not Yarn Berry (v2+)

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-22 09:51:58 -05:00
PapacyDai 84c6e6fd30 fix: prevent find flags from leaking into fill value (#955) 2026-03-21 11:24:31 -05:00
Chris Tate 7e5baa6d77 Fix viewport dimensions in streaming status messages and screencast (#952)
## Summary
Fixed an issue where custom viewport settings were ignored in WebSocket streaming, causing status messages to always report hardcoded dimensions (1280x720) instead of the actual viewport size.

## Changes Made
- **Added viewport tracking to StreamServer**: New `viewport_width` and `viewport_height` fields to store current dimensions
- **Updated viewport synchronization**: Modified `handle_viewport()` and `handle_device()` to update the stream server when viewport changes
- **Fixed status message broadcasting**: Replaced hardcoded dimensions with actual viewport values in `broadcast_status()` calls
- **Improved screencast defaults**: Changed screencast to use stored viewport dimensions as defaults instead of hardcoded 1280x720
- **Added viewport getter methods**: New `set_viewport()` and `viewport()` methods on StreamServer for dimension management

This ensures that WebSocket clients receive consistent viewport dimensions across all message types (status and frame messages), matching the actual browser viewport settings.

Fixes #950
2026-03-20 19:16:39 -05:00
jin.2andhyunjinee 59b6e5a034 feat: support cross-origin iframe snapshots and interactions via Target.setAutoAttach (#949)
* feat: support cross-origin iframe snapshots and interactions via Target.setAutoAttach (#925)

Enable Target.setAutoAttach with flatten: true on page sessions so Chrome
auto-creates dedicated CDP sessions for cross-origin iframe targets.

- Add DrainedEvents struct, iframe_sessions map, attach/detach event handling
- Extract resolve_ax_session (shared) and resolve_frame_session helpers
- resolve_element_object_id returns (object_id, effective_session) tuple
- resolve_element_center returns (x, y, effective_session) tuple
- Input dispatch (click/hover/tap) uses effective session for correct coordinates
- Thread iframe_sessions through element.rs, interaction.rs, screenshot.rs
- Clear iframe sessions on navigate, tab switch, tab new, tab close
- Ignore Target.setAutoAttach failure for non-Chrome backends (Lightpanda)
- Unit tests for session resolution logic

* fix

* fix

* fix

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-20 16:37:33 -05:00
github-actions[bot] 39e54113e6 chore: version packages (#948)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-20 09:13:13 -05:00
Kevin Kipp aed466b347 fix: make auth login selector targeting more reliable (#945)
Navigate with load, then wait for username/password/submit selectors using the default action timeout. This avoids networkidle hangs on pages with continuous background requests.
2026-03-20 09:01:00 -05:00
github-actions[bot] 8d14ecb92b chore: version packages (#947)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-20 08:53:47 -05:00
Chris Tate 6daad22ada chore: patch release - ### Bug Fixes
- **WebSocket keepalive for remote ... (#946)
2026-03-20 08:36:04 -05:00
9837b9c1aa perf: fast-path identical snapshots in diff_snapshots (#922)
* Update agent-browser optimization plan: PR #916 submitted, Phase 2 complete

Co-authored-by: Hermes <agent@hermes.ai>

* perf(snapshot-diff): fast-path identical snapshots

Co-authored-by: Hermes <agent@hermes.ai>

* chore(pr): remove unrelated docs from snapshot-diff fast-path

---------

Co-authored-by: Merlin <merlin@rbeckner.com>
Co-authored-by: Hermes <agent@hermes.ai>
2026-03-19 20:02:43 -05:00
jin.2andhyunjinee af800f8403 fix: support xpath= selector prefix in element resolution (#908)
* fix: support xpath= selector prefix in element resolution

Resolves #907. When a selector starts with "xpath=", use
document.evaluate() instead of document.querySelector() so that
XPath expressions like "xpath=//button" work correctly.

* test: replace overlapping test with edge case tests

Replace test_build_selector_js_xpath_strips_prefix (which overlapped
with the xpath test) with two edge case tests: empty xpath and
selector starting with "xpath" without "=" delimiter.

* fix: support xpath= selector in resolve_element_object_id

Apply the same xpath= handling to resolve_element_object_id, which is
used by type, fill, focus, hover, check, select, screenshot, drag,
and all other selector-based commands beyond basic click.

* refactor: extract build_find_element_js to deduplicate xpath/css logic

The xpath= vs querySelector branching was duplicated in both
build_selector_js and resolve_element_object_id. Extract the shared
logic into build_find_element_js and reuse it in both places.

* fix: support xpath= selector in get_element_count

Use ORDERED_NODE_SNAPSHOT_TYPE with snapshotLength for XPath counting,
matching the querySelectorAll().length behavior for CSS selectors.

* refactor: rename find to find_expr for clarity

* refactor: extract build_count_elements_js and add regression tests

Extract element counting JS generation into build_count_elements_js
helper (matching the pattern of build_find_element_js) and add tests
for both CSS and XPath counting paths.

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-19 19:27:03 -05:00
Chris Tateandctate 421f8fab82 fix: add WebSocket keepalive to prevent CDP connection drops on remote browsers (#936)
The v0.20.0 Rust rewrite dropped two keepalive mechanisms that the
Node.js/Playwright daemon provided, causing CDP connections to remote
Browserless instances to silently die between commands when traversing
multi-hop proxy topologies (Istio Envoy, OpenResty, etc.).

Restore parity with v0.19.0 and improve on it:

1. TCP SO_KEEPALIVE (v0.19.0 parity): Playwright's WebSocketTransport
   used HTTP agents with keepAlive: true, which set SO_KEEPALIVE on the
   underlying TCP socket. Restored via socket2::SockRef on the
   tokio_tungstenite stream before splitting.

2. WebSocket Ping frames (improvement): Send Ping frames every 30s on
   idle connections. This goes beyond v0.19.0 because L7 proxies (Envoy,
   nginx, OpenResty) can see WebSocket pings but not TCP keepalive
   probes, making this effective through application-layer proxy hops.

The keepalive task is coordinated with the reader loop via a watch
channel and stops automatically when the connection closes.

Fixes #934

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-19 19:12:57 -05:00
mikewong23571andClaude Opus 4.6 2b3c5c26cd docs: create providers/ section with dedicated provider pages (#928)
Add dedicated documentation pages for each cloud browser provider:
Browser Use, Browserbase, Browserless, and Kernel (sorted
alphabetically). Uses providers/ path per maintainer feedback.

- Fix Kernel defaults to match source code (KERNEL_HEADLESS=true,
  KERNEL_STEALTH=false) — README had these inverted
- Add Providers section to sidebar navigation
- Simplify cdp-mode cloud providers section to link to new pages

Fixes part of #774

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 02:40:33 -05:00
mikewong23571andClaude Opus 4.6 2d37967b7e docs: fix desktop browser list in iOS comparison table (#926)
The codebase only has CDP launchers for Chrome and Lightpanda
(cdp/chrome.rs, cdp/lightpanda.rs). Firefox and WebKit have no
launcher and are not supported.

Fixes part of #774

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 01:50:42 -05:00
Chris Tate 06b3b94493 colors + search for docs (#927)
* colors

* search
2026-03-19 01:50:19 -05:00
mikewong23571andClaude Opus 4.6 0749ad667e docs: migrate page metadata from MDX to layout.tsx (#904)
Move JS metadata exports out of page.mdx files into per-directory
layout.tsx files. MDX files now contain pure markdown content, making
them render cleanly on GitHub without visible JS import/export lines.

No content changes — only the metadata mechanism is relocated.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 01:25:17 -05:00
github-actions[bot] fa563b59b9 chore: version packages (#920)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-18 19:54:32 -05:00
Chris Tate 757626f27c chore: add patch changeset for release (#919) 2026-03-18 17:02:34 -05:00
755fa50400 fix: handle relative URLs in Websocket domain filter script (#624)
* fix: handle relative URLs in domain filter WebSocket script

Pass location.href as base URL to the URL constructor so relative URLs
(e.g. "/path" or "//host/path") resolve correctly instead of throwing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Affirm-Skill: commit-and-push

* fix: apply domain filter review followups

Use location.href as base in native WebSocket handler to match
EventSource/sendBeacon, remove redundant comment, add test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-18 16:56:03 -05:00
fdc09c95f4 fix: restore origin-scoped --headers persistence across commands (#894)
* fix: restore origin-scoped --headers persistence across commands

In the v0.20 Rust rewrite, headers passed via --headers on open were
only applied to that single navigation via Network.setExtraHTTPHeaders,
which did not persist them for subsequent commands. In v0.19
(Playwright-based), these headers persisted for all subsequent
same-origin requests.

This restores the v0.19 behavior using CDP Fetch interception:

- A background task processes Fetch.requestPaused events in real-time,
  injecting origin-scoped headers into matching requests and continuing
  non-matching requests unmodified. This avoids the deadlock that occurs
  when Fetch interception pauses requests during Page.navigate or
  Runtime.evaluate (which block waiting for completion).

- The same background task also handles domain filtering and route
  interception, replacing the previous drain-between-commands approach
  that couldn't process events during navigation or script evaluation.

Fixes:
- --headers persist for same-origin navigations without re-passing flag
- --headers persist for in-page fetch/XHR to the same origin
- --headers do not leak to cross-origin navigations or sub-resources
- `set headers` (global) is unaffected and stacks with --headers
- Domain filter Fetch interception no longer deadlocks during navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: apply cargo fmt formatting

* revert inaccurate comment change

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-18 16:52:21 -05:00
Chris Tateandctate 486e1b341f Fix Chrome headless launch failures with --enable-unsafe-swiftshader (#915)
* Fix Chrome headless launch failures by adding --disable-gpu flag

Fixes silent Chrome crashes in headless mode when GPU drivers are unavailable or restricted (common in VMs, containers, and cloud environments).

## Changes Made

- **Auto-add `--disable-gpu` flag**: Automatically includes `--disable-gpu` when launching Chrome in headless mode to prevent GPU initialization crashes
- **Improved error reporting**: Enhanced error messages to include Chrome's exit code when it crashes before writing DevToolsActivePort
- **Better user guidance**: Added helpful hints in error messages suggesting `--no-sandbox` and `--disable-gpu` flags for troubleshooting
- **Updated tests**: Added test coverage for the new `--disable-gpu` flag behavior

## Implementation Details

The `--disable-gpu` flag is only added in headless mode (when `options.headless && !has_extensions`), preserving GPU acceleration for non-headless usage. The error handling now captures Chrome's exit code and provides actionable debugging information when Chrome fails silently.

Fixes #914

* Use --enable-unsafe-swiftshader instead of --disable-gpu for Playwright parity

--disable-gpu disables all GPU acceleration and breaks WebGL on Chrome 130+.
Playwright uses --enable-unsafe-swiftshader to enable CPU-based software
rendering via SwiftShader, which prevents GPU-driver crashes while preserving
WebGL support. This matches the behavior from v0.19 (Playwright-based daemon).

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-18 16:30:09 -05:00
Daniel VershininandDaniel Vershinin 5b5dffe0a2 Fix raw native mouse drag state across down/move/up (#872)
* Fix native mouse down/move/up state

* Allow clippy too_many_arguments for mouse helper

---------

Co-authored-by: Daniel Vershinin <d@sgml.me>
2026-03-18 13:29:33 -05:00
Lppyand羲洋 4be4605543 fix: dedup text content in snapshot (#909)
* fix: dedup text content in snapshot

* fix: format

---------

Co-authored-by: 羲洋 <lipengyang.lpy@alibaba-inc.com>
2026-03-18 10:59:56 -05:00
github-actions[bot] d03f6431ea chore: version packages (#911)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-18 10:49:35 -05:00
Chris Tate 1e7619d59d chore: add patch changeset for release (#910) 2026-03-18 10:39:34 -05:00
Chris Tate 8cfba1752d feat: add built-in upgrade command for self-update (#898)
Adds a new `agent-browser upgrade` command that automatically detects the installation method (npm, Homebrew, or Cargo) and runs the appropriate update command.

**Changes:**
- Added new `upgrade.rs` module with upgrade logic
- Updated `main.rs` to handle the `upgrade` command
- Added upgrade help text in `output.rs`
- Updated README.md and documentation with upgrade instructions
- Updated SKILL.md to mention the upgrade command

**Implementation details:**
- Fetches latest version from npm registry to show version diff
- Auto-detects installation method by checking Homebrew, Cargo paths, and npm global packages
- Provides fallback instructions if installation method cannot be determined
- Uses existing color module for consistent styled output
- Gracefully handles network failures and continues with upgrade

Fixes #895
2026-03-17 23:29:07 -05:00
Cauê FelcharandClaude Sonnet 4.6 b8a5fc7101 feat: add HAR 1.2 network capture commands (#864)
* feat: enhance HAR entries with timings, cookies, postData, and protocol normalisation

Extends #874's HarEntry struct and CDP handlers with richer capture:

- wall_time (f64) replaces pre-formatted started_date_time, preserving
  sub-second precision from CDP wallTime for accurate RFC 3339 output
- request_headers / response_headers changed from Value to Vec<(String,String)>
  for typed access without re-parsing JSON objects
- post_data captured from Network.requestWillBeSent for HAR postData
- cdp_timing and loading_finished_timestamp captured from responseReceived
  and loadingFinished respectively, enabling har_compute_timings to produce
  accurate blocked/dns/connect/ssl/send/wait/receive phases
- har_cdp_protocol_to_http_version normalises CDP protocol strings
  (h2 -> HTTP/2.0, h3 -> HTTP/3.0, etc.)
- Request cookies parsed from Cookie header; response cookies from Set-Cookie
  with ';'-first split to correctly strip Path/HttpOnly attributes before '='
- har_wall_time_to_rfc3339 replaces har_started_date_time, using the time
  crate directly on the f64 epoch value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: apply cargo fmt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:27:27 -05:00
github-actions[bot] 8fc1d000f7 chore: version packages (#889)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-17 14:25:04 -05:00
Chris Tate c6de80b95e prepare v0.21 (#886) 2026-03-17 13:38:54 -05:00
Chris Tateandctate 1cd90078b4 fix: prevent system package removal during Ubuntu dependency install (#884)
* fix: prevent system package removal during Ubuntu dependency install

This PR fixes a critical issue where installing agent-browser dependencies on Ubuntu 24.04+ could trigger removal of hundreds of system packages due to library naming conflicts during the 64-bit time_t transition.

**Problem:**
On Ubuntu 24.04+, many core libraries were renamed with a "t64" suffix as part of the 64-bit time_t transition. The installer was trying to install old library names, causing apt to propose removing 400+ essential system packages to resolve conflicts.

**Changes:**
- Added comprehensive t64 variant detection for all apt dependencies
- Implemented pre-install simulation check to detect potential package removals
- Added safety abort mechanism when removals are detected (>0 packages)
- Enhanced error messaging to explain the issue and provide manual installation guidance
- Improved logging and user feedback during the installation process

**Implementation Details:**
- Uses `apt-get install --simulate` to safely check for conflicts before proceeding
- Checks for t64 variants first, falls back to original names if t64 versions don't exist
- Provides detailed output showing which packages would be removed
- Maintains compatibility with older Ubuntu versions that don't have t64 packages

Fixes #881

* refactor: extract duplicate install status handling into helper

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-17 11:32:07 -05:00
Mateusz Burzyński f8eb38c7f1 fix: use socket connectivity alone instead combining it with PID check for daemon liveness (#879) 2026-03-17 11:29:01 -05:00
Chris Tateandctate 60f3afcf61 Add iframe support for CLI interactions and snapshots (#869)
* Add iframe support for CLI interactions and snapshots

This PR adds comprehensive iframe support to the agent browser CLI, allowing users to interact with elements inside iframes seamlessly.

## Problem
Users couldn't interact with elements inside iframes via the command line. The existing `frame` command was non-functional as it set `active_frame_id` but no other code read this value.

## Changes Made

### Enhanced Frame Context Tracking
- Added `frame_id` field to `RefEntry` to track which frame each element reference belongs to
- Updated `RefMap::add` and related methods to accept and store frame context
- Modified element resolution functions to use frame context from ref entries

### Improved Frame Command
- Fixed the existing `frame` command to actually work by threading `active_frame_id` through snapshot operations
- Added support for iframe element references (e.g., `frame @e2`) in addition to CSS selectors
- Enhanced frame detection to work with both named frames and iframe elements

### Updated Snapshot Behavior
- Modified `take_snapshot` to accept optional frame context parameter
- Updated all snapshot call sites to pass appropriate frame context
- Maintained backward compatibility while enabling frame-scoped operations

### Element Resolution Updates
- Updated `resolve_element_center` and `resolve_element_object_id` to use frame context from ref entries
- Modified `find_node_id_by_role_name` to support frame-specific element lookup
- Ensured all interaction functions work correctly within iframe contexts

## Implementation Details
- Frame context is now properly propagated through the entire element interaction pipeline
- The `frame` command can accept both CSS selectors and element references
- All existing functionality remains intact while adding iframe capabilities
- Added `Iframe` to interactive roles for better element discovery

Fixes #863

* docs: add iframe support documentation

Document the new iframe capabilities across all documentation surfaces:
- Auto-inlining of iframe content in snapshots
- Direct interaction with iframe element refs
- frame command support for element refs (@e3)
- Scoped snapshots via frame switching

* fix: pass active frame context to diff snapshots and fix nameless iframe lookup

- handle_diff_snapshot now respects active_frame_id instead of always
  passing None, so diff snapshots work correctly inside iframes
- Nameless/id-less iframes now fall back to src URL (or null) instead of
  the literal string 'frame' which never matched any frame in the tree

* fix: resolve iframe frame ID via DOM.describeNode and reduce code duplication

- handle_frame: Use DOM.describeNode + contentDocument.frameId to resolve
  iframe frame IDs directly, fixing failures for nameless iframes that
  lack name/id/src attributes
- element.rs: Deduplicate add() by delegating to add_with_frame()
- snapshot.rs: Guard against out-of-bounds insert_str when iframe marker
  is on the last line without a trailing newline

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-17 10:51:17 -05:00
Chris Tateandctate f51e955d99 refactor: make --full/-f a command-level flag instead of global (#877)
* refactor: make --full/-f a command-level flag instead of global

Move --full/-f from global flags (parsed in flags.rs) to command-level
parsing in commands.rs, scoped to the three commands that actually use
it: `screenshot`, `diff screenshot`, and `diff url`.

This frees up `-f` for other commands (e.g. `--follow` on
`console`/`errors`, see #867) and better reflects that full-page
capture is not a global concern.

Changes:
- Remove `full` from Flags struct, Config struct, and global flag parsing
- Remove `--full`/`-f` from clean_args global boolean flags list
- Parse `--full`/`-f` inline in `screenshot` command handler
- Accept `-f` shorthand in `diff screenshot` and `diff url` (previously
  only `--full` was accepted at command level)
- Remove fallback from global `flags.full` in diff subcommands
- Update tests to pass --full as a command argument rather than a global flag

Fixes #876

* fix: remove stale AGENT_BROWSER_FULL env var from help and add -f shorthand tests

- Remove AGENT_BROWSER_FULL from help text in output.rs since the env
  var is no longer read after moving --full to command-level parsing
- Add test_screenshot_full_page_shorthand to verify screenshot -f works
- Add test_diff_screenshot_command_full_flag_shorthand to verify
  diff screenshot -f works

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-17 10:40:53 -05:00
jin.2andhyunjinee 59ea02cc8e feat: fall back to WebSocket when HTTP discovery fails (fixes #870) (#873)
* feat: fall back to ws://host:port/devtools/browser when HTTP discovery
  fails

  Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
  CDP over WebSocket but does not serve /json/version or /json/list HTTP
  endpoints. Add a third fallback in discover_cdp_url_with_timeout() that
  connects directly to ws://host:port/devtools/browser and verifies the
  endpoint with Browser.getVersion.

  Fixes #870

* chore

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-17 09:15:44 -05:00
Chris Yang 8dd012f4fd feat: add network har start/stop command for HAR 1.2 export (#874)
Expose HAR recording as a CLI subcommand under the existing `network`
command so users can capture and export network traffic without a
separate tool or opening the browser twice.

- Parse `network har start` and `network har stop [path]` in commands.rs
- Enrich HarEntry with request/response headers, timestamps, status text,
  resource type, HTTP version, and body sizes from CDP events
- Produce HAR 1.2 output with creator/browser metadata, query strings,
  and proper header arrays compatible with Chrome DevTools HAR viewer
- Auto-generate output path under ~/.agent-browser/tmp/har/ when omitted
- Add har_stop to skip_launch list so export works without a live browser
- Update help text, README, docs site, SKILL.md, and security policy docs
- Add unit tests for parsing, HAR entry serialization, and stop behavior
2026-03-17 09:02:39 -05:00
简简aw 663e10355a Enhance Chrome launch process with user-data-dir and timeout (#852)
* fix: improve Chrome launch process by enhancing user-data-dir handling and adding timeout for DevToolsActivePort

* fix: enhance Chrome launch process by improving user data directory handling and timeout management for DevToolsActivePort

* fix: remove unused wait_for_ws_url function to streamline Chrome launch process
2026-03-17 08:54:59 -05:00
7734bb2702 feat: add batch command for multi-step workflows (#865)
Add `batch` command that reads a JSON array of commands from stdin
and executes them sequentially against the daemon. This avoids
per-command process startup overhead when AI agents run multi-step
browser workflows.

Supports --bail to stop on first error (default: continue all)
and --json for structured output as an array of results.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:48:50 -05:00
a865dd56e0 fix: fall back to /json/list when /json/version is unavailable (#861)
* fix: fall back to /json/list when /json/version is unavailable

Chrome's UI-based remote debugging mode (the permission dialog flow)
only exposes a WebSocket endpoint and does not serve /json/version.
Discovery now tries /json/version first, then falls back to /json/list
to find the browser target's WebSocket URL.

Fixes #628

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: preserve original error message when /json/list fallback fails

When both /json/version and /json/list fail, return the original
/json/version error instead of a wrapped message. This preserves the
error format expected by callers like lightpanda's timeout test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:53:04 -05:00
github-actions[bot] 9a8d9f439b chore: version packages (#860)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-16 19:17:59 -05:00
Chris Tate 664789f5c6 fix: use DOM textContent as fallback name for cursor-interactive snapshot nodes (#859)
Generic elements (e.g. <div>) with cursor:pointer/onclick have empty ARIA
names because their text lives in StaticText children, which get filtered
in interactive mode. Fall back to the JS-collected textContent so the text
appears on the rendered tree line.

Fixes e2e_snapshot_cursor_many_elements CI failure from #855.
2026-03-16 18:26:22 -05:00
Chris Tate c0d4cf6a93 chore: add patch changeset for release (#858) 2026-03-16 17:37:08 -05:00
Ayush Rajgorandctate 48a265057b fix: Windows auto-connect profiling (#835) (#840)
* fix: Windows auto-connect profiling (#835)

Fix three interrelated bugs causing `--auto-connect` to fail on Windows,
plus a UX issue where auto-connect hijacked existing tabs:

1. Stale DevToolsActivePort — add TCP port liveness check before returning
   M144+ WebSocket URL; remove stale files when port is dead.

2. Missing Windows error codes — add os error 10061 (WSAECONNREFUSED) and
   10054 (WSAECONNRESET) to is_transient_error() so daemon startup races
   are retried on Windows.

3. --auto-connect not propagated to daemon — add auto_connect to
   DaemonOptions, set AGENT_BROWSER_AUTO_CONNECT env var via
   apply_daemon_env(), and guard the headed launch block so it doesn't
   send a second launch that overrides the auto-connect.

4. Auto-connect opens a fresh tab — after connecting to an existing
   Chrome, create a new about:blank tab and bring it to front so
   navigations don't hijack the user's existing tabs.

Made-with: Cursor

* fix: address review feedback — cargo fmt, shared helper, Windows tests

- Run cargo fmt on is_port_reachable() formatting
- Extract duplicated auto-connect-with-fresh-tab logic into
  connect_auto_with_fresh_tab() helper used by both handle_launch()
  and auto_launch()
- Add unit tests for Windows WSAECONNREFUSED (os error 10061) and
  WSAECONNRESET (os error 10054) in is_transient_error()

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-16 17:27:39 -05:00
0705b4ddac fix: propagate --cdp flag to daemon for reliable CDP reconnection (#857)
* fix: propagate --cdp flag to daemon via AGENT_BROWSER_CDP env var

The --cdp flag was not being passed to the daemon process as an environment
variable, causing auto-reconnection to fail. The daemon's auto_launch()
function checks for AGENT_BROWSER_CDP, but this was never set when spawning
the daemon.

This commit adds:
- cdp field to DaemonOptions struct
- AGENT_BROWSER_CDP env var setting in apply_daemon_env()
- flags.cdp propagation in main.rs

This ensures reliable CDP connection recovery when using --cdp with external
browsers like Lightpanda, Electron apps, or remote Chrome instances.

Fixes reconnection issues with --cdp flag after connection drops.

* chore: remove changeset

---------

Co-authored-by: Jake Shore <jakeshore@Jakes-Mac-mini.local>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-16 16:31:51 -05:00
Lppyand羲洋 811e66f99e feat: embed cursor-interactive elements into snapshot tree (#855)
* feat: embed cursor-interactive elements into snapshot tree

* optimize format

* fix: address review feedback for e2e_snapshot_cursor_interactive unitest

---------

Co-authored-by: 羲洋 <lipengyang.lpy@alibaba-inc.com>
2026-03-16 16:28:31 -05:00
42c4c56c9a feat: add --idle-timeout CLI flag for daemon auto-shutdown (#856)
* feat: add --idle-timeout CLI flag for daemon auto-shutdown

Add user-friendly --idle-timeout flag that converts time strings
to milliseconds. Supports formats like '10s', '3m', '1h', or raw ms.

This addresses a common need for ephemeral/CI environments where
daemon processes can be orphaned if not explicitly closed, leading
to resource consumption from zombie chrome-headless-shell processes.

Co-authored-by: Hermes (via claude-sonnet-4-20250520) <agent@hermes.ai>

* fix: address idle-timeout review feedback

* fix: normalize idle-timeout parsing

---------

Co-authored-by: Merlin <merlin@rbeckner.com>
Co-authored-by: Hermes (via claude-sonnet-4-20250520) <agent@hermes.ai>
2026-03-16 15:20:16 -05:00
0883813cd3 fix: support remote host in CDP discovery (#854)
* fix: support remote host in CDP discovery (#851)

  `discover_cdp_url` now accepts a host parameter instead of hardcoding
  127.0.0.1, allowing `connect "http://<remote-ip>:<port>"` to query the
  correct remote `/json/version` endpoint. The returned webSocketDebuggerUrl
  is rewritten to match the requested host and port, since Chrome always
  reports 127.0.0.1 regardless of the interface it was reached through.

* style: apply cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: unify discover_cdp_url and discover_cdp_url_with_request_timeout

Merge the two discovery functions into discover_cdp_url(host, port) and
discover_cdp_url_with_timeout(host, port, timeout), eliminating duplicated
logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: merge discover_cdp_url into single function with optional timeout

Replace discover_cdp_url + discover_cdp_url_with_timeout with a single
discover_cdp_url(host, port, timeout) where timeout is Option<Duration>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace Option<Duration> with separate discover_cdp_url_with_timeout

Split back into two functions for cleaner call sites:
- discover_cdp_url(host, port) for default timeout
- discover_cdp_url_with_timeout(host, port, timeout) for custom timeout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bracket IPv6 addresses in CDP discovery HTTP URL

Extract bracket_ipv6 helper and apply it in fetch_cdp_info to produce
valid URLs like http://[::1]:9222/json/version instead of malformed
http://::1:9222/json/version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 08:37:23 -05:00
github-actions[bot] 8163f6cdca chore: version packages (#850)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-16 00:31:08 -05:00
Chris Tate eda956b754 chore: add patch changeset for release (#849) 2026-03-16 00:22:08 -05:00
Chris Tate d866ee2022 Fix network idle detection for cached pages by observing 500ms idle period (#847)
The `wait --load networkidle` command was incorrectly returning immediately when pages were served from cache, causing subsequent commands to fail. This happened because the network idle logic would return instantly when no network requests were pending, without observing any idle period.

## Changes Made

- Extract network idle polling logic into a separate `poll_network_idle` function for better testability
- Fix the timeout handling to start a 500ms idle timer when no requests are pending, instead of returning immediately
- Add comprehensive unit tests covering the regression case and normal network request flows
- Ensure the function always observes at least 500ms of network inactivity before resolving, even for cached pages

## Key Fix

The critical change is in the timeout branch: when no CDP events arrive within 600ms, we now start the idle timer if no requests are pending, rather than returning `Ok(())` immediately. This prevents false-positive idle detection for pages that load entirely from cache.

Fixes #846
2026-03-15 23:11:41 -05:00
github-actions[bot] ebfabe0e62 chore: version packages (#845)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 20:57:08 -05:00
Chris Tate 5fa239676b chore: add patch changeset for release (#844) 2026-03-15 20:48:08 -05:00
Chris Tateandctate 285eab46df fix: resolve snapshot -C and screenshot --annotate hang over WSS (#842)
* fix: resolve snapshot -C and screenshot --annotate hang over WSS (#841)

Root cause: sequential CDP round-trips per element in
find_cursor_interactive_elements() and collect_annotations() caused
timeouts over high-latency WSS connections (~200ms × 200+ elements
exceeds the 30s CDP timeout).

Fix:
- snapshot -C: Replace per-element CDP calls with a single JS eval
  that detects cursor:pointer/onclick/tabindex elements in-browser,
  then batch-resolve via DOM.querySelectorAll + concurrent
  DOM.describeNode calls using join_all
- screenshot --annotate: Replace sequential DOM.resolveNode +
  getRect calls with concurrent join_all, matching v0.19.0's
  Promise.all() pattern

Behavioral parity with v0.19.0 (Node.js/Playwright):
- cursor:pointer detection via getComputedStyle
- Inherited cursor:pointer dedup (skip children of pointer parents)
- interactiveTags and interactive ARIA roles exclusion
- Role differentiation: clickable vs focusable
- Text dedup against ARIA tree ref names and quoted strings
- Edge case: -i -C shows cursor elements even when ARIA tree is empty

Tests:
- 5 unit tests for build_dedup_set() helper
- 3 e2e regression tests: cursor-interactive detection, annotation
  scaling to 50 elements, cursor scaling to 100 elements

* fix: add hidden/aria-hidden filtering, contentEditable support, and cleanup robustness

- Restore hidden/aria-hidden element filtering in cursor-interactive JS
  (was present in old code, dropped during rewrite)
- Add contentEditable detection with 'editable' role and hint
- Replace fire-and-forget cleanup with warning on failure
- Simplify build_dedup_set to use ref_map only (eliminates fragile
  tree-text quote parsing; ref_map already has all ref-bearing names)

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-15 20:41:30 -05:00
github-actions[bot] 5b74604a5c chore: version packages (#839)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 14:14:01 -05:00
Chris Tate 4b5fc78f71 chore: add patch changeset for release (#838) 2026-03-15 14:05:01 -05:00
Chris Tateandctate c092ffd82b fix: use correct VK codes for punctuation in type command (#836)
* fix: use correct Windows virtual-key codes for punctuation in type command

The `type` command was dropping punctuation characters like `.`, `'`, and
`#` because `char_to_key_info()` used raw ASCII codes as the
`windowsVirtualKeyCode` in CDP `Input.dispatchKeyEvent` calls. For
punctuation the ASCII value collides with unrelated VK codes — most
critically '.' (ASCII 46) equals VK_DELETE (0x2E), causing Chrome to
interpret periods as Delete key presses.

Changes:
- Add `punctuation_key_info()` with correct VK_OEM_* codes matching
  Playwright's USKeyboardLayout (e.g. Period=190, Slash=191, Semicolon=186)
- Fall back to `Input.insertText` for characters without a US keyboard
  mapping (emoji, CJK, etc.), matching Playwright's `keyboard.type()`
- Update e2e test to use `type` instead of `fill` workaround for email
- Add unit tests verifying VK code parity with Playwright's layout

Fixes #833

* style: fix rustfmt formatting for InsertTextParams

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-15 13:57:15 -05:00
Chris Tateandctate 8ac7fe916e fix: restore Playwright-parity check/uncheck for Material Design controls (#837)
The v0.20.0 migration from Playwright to the native Rust daemon introduced
two regressions in checkbox/radio handling:

1. `is_element_checked` only read `this.checked`, which is undefined on
   non-input elements. Material Design and ARIA controls use wrapper divs
   with `role="checkbox"` and `aria-checked`, or hide the native input
   off-screen inside a label. The function now mirrors Playwright's
   `getChecked()` with follow-label retargeting: native `.checked`,
   `aria-checked` for ARIA roles, `label.control` traversal, and nested
   input lookup.

2. `check`/`uncheck` accepted the coordinate-based CDP click result
   without verifying the state actually changed. When the AX tree's
   `backendDOMNodeId` points to a hidden off-screen input (common in
   Material Design), `Input.dispatchMouseEvent` hits nothing. The actions
   now re-check state after clicking and fall back to a JS `.click()` on
   the resolved input — matching Playwright's `_setChecked` verify step.

Adds e2e regression test covering Material Design (hidden input + ripple
overlay), ARIA-only, and native checkbox patterns.

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-15 13:38:39 -05:00
github-actions[bot] 1fd8e9d09a chore: version packages (#831)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 10:28:01 -05:00
Chris Tate a3d966244e chore: add patch changeset for release (#830) 2026-03-15 10:14:01 -05:00
Matt Van HornandMatt Van Horn 8348b77800 fix: filter chrome:// internal targets from auto-connect discovery (#827)
When using --auto-connect, discover_and_attach_targets() was selecting
Chrome internal pages (chrome://, chrome-extension://, devtools://) as
the active target. Follow-up commands like `get url` and `snapshot`
would then return data from targets like chrome://omnibox-popup.top-chrome/
instead of the actual application tab.

Add is_internal_chrome_target() filter to exclude internal Chrome targets
from the discovery results. If no user-facing targets remain after
filtering, the existing "create a new tab" fallback handles it.

Fixes #813

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-03-15 10:13:22 -05:00
Chris Tateandctate 609f32c986 fix: restore WebSocket streaming in native daemon (#826)
* fix: restore WebSocket streaming in native daemon

The v0.20.0 Rust rewrite broke WebSocket streaming — connections opened
but received zero messages before closing. Multiple issues contributed:

1. StreamServer was dropped immediately after creation in daemon.rs,
   closing the broadcast channel and killing all WS connections.

2. Screencast frames were only processed during command polling
   (drain_cdp_events) instead of in real-time, unlike the 0.19.0
   TypeScript cdp.on('Page.screencastFrame') callback.

3. Auto-start/stop screencast on WS client connect/disconnect was
   missing from the Rust implementation.

4. Screencast CDP commands used the wrong session ID (daemon session
   name instead of the CDP page session from Target.attachToTarget).

5. Broadcast channel Lagged errors killed WS connections instead of
   being handled gracefully.

The fix adds a background CDP event loop in StreamServer that subscribes
to Chrome events and broadcasts screencast frames in real-time, properly
tracks the CDP page session ID, restores auto-screencast lifecycle, and
keeps the StreamServer alive in DaemonState.

Fixes #820

* fix: use actual CDP session ID for input dispatch in stream WebSocket

Pass the real cdp_session_id (from Target.attachToTarget) through to
handle_ws_client instead of an empty string. Previously, input commands
(mouse, keyboard, touch) were sent with `"sessionId": ""` which Chrome
silently rejects. Now the correct page session ID is read at dispatch
time, and when no session ID is set yet (before browser launch),
the field is omitted entirely via `None` so Chrome uses browser-level
dispatch.

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-15 10:00:28 -05:00
github-actions[bot] 79f464d47a chore: version packages (#829)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 09:50:01 -05:00
Chris Tate 51d9ab4d49 chore: add patch changeset for release (#828) 2026-03-15 09:42:01 -05:00
Chris Tateandctate 6636ac0e74 fix: snapshot --selector scopes to the matched element subtree (#825)
* fix: snapshot --selector scopes to the matched element subtree

The native Rust daemon accepted the --selector flag but never used it —
the full accessibility tree was always returned regardless of the
selector.  This restores the 0.19.0 behaviour where snapshot --selector
returns only the subtree rooted at the matched CSS selector.

The implementation resolves the selector via Runtime.evaluate, fetches
the full DOM subtree with DOM.describeNode(depth: -1) to collect all
descendant backendNodeIds, then filters the AX tree to render only the
nodes whose backendDOMNodeId falls within that set.  This correctly
handles elements like <body> that don't map to a direct AX node.

Also fixes handle_snapshot reading "depth" instead of "maxDepth" from
the command JSON, which caused --depth to be silently ignored.

Fixes #822

* style: run cargo fmt on snapshot.rs

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-15 09:22:55 -05:00
bc94eaf94f fix: add appium: vendor prefix to iOS capabilities for Appium v3 (#810)
* fix: add appium: vendor prefix to iOS capabilities for Appium v3

Appium v3 enforces the W3C WebDriver spec strictly, requiring
non-standard capabilities to use vendor prefixes. The iOS provider
was sending capabilities like `automationName`, `noReset`, `deviceName`,
`platformVersion`, and `udid` without the required `appium:` prefix,
causing session creation to fail with InvalidArgumentError.

This change prefixes all non-standard capabilities with `appium:` while
leaving standard W3C capabilities (`platformName`, `browserName`)
unprefixed. Backwards-compatible with Appium v2, which accepts both
formats.

Fixes #629

* fix: extract build_ios_capabilities for testable production code path

Addresses review feedback: removes unused `mut manager` warning and
validates the actual capability-building logic instead of reconstructing
JSON inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 09:22:32 -05:00
github-actions[bot] 77a27fe6a3 chore: version packages (#824)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 08:25:01 -05:00
Chris Tate daf7263385 chore: add patch changeset for release (#823) 2026-03-15 08:16:02 -05:00
0745db44a2 fix: remove obsolete BrowserManager TypeScript API from README (#821)
* fix: remove obsolete BrowserManager TypeScript API references from README

The TypeScript src/ was removed in commit 8e43469 (full native #754),
but README still referenced the non-existent BrowserManager API.
Replace Lambda example with CLI-based handler and remove Programmatic API section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:05:24 -05:00
jin.2andhyunjinee 19ba9048a0 fix: recording produces correct video duration with real-time ffmpeg encoding (#812)
* fix: replace screenshot polling with screencast-based piped ffmpeg recording

  Recording previously used Page.captureScreenshot polling at 10fps,
  which was CPU-heavy and produced inconsistent results. Now uses
  Page.startScreencast with throttled acks (35ms interval) to receive
  frames event-driven from Chrome, and pipes JPEG data directly to
  ffmpeg stdin in real-time instead of saving temp files.

  - Spawn ffmpeg at recording start with piped stdin (image2pipe)
  - Background task receives screencast frames, interpolates gaps by
    repeating the last frame based on timestamps, targets 25fps
  - Ack throttling controls Chrome's frame push rate
  - Fix: current frame was never written after the first one
  - Fix: frame count was read before task finished padding
  - Remove tokio-util dependency (replaced CancellationToken with oneshot)
  - Add tokio "process" feature for async child process stdin pipe
  - Extract start/stop_recording_task helpers on DaemonState
  - Add tests for restart, ffmpeg codec selection, and stop without task

* fmt

* chore

* fix: switch WebM codec from VP9 to VP8 for correct framerate and browser
  compatibility

  VP9 realtime encoder ignored input framerate, producing 10fps output
  instead of 25fps. This caused inconsistent playback in browsers.
  VP8 respects -framerate 25 and has wider browser playback support.

* fmt

* fix: add kill_on_drop to ffmpeg process to prevent zombie on task panic

* fix: switch from screencast to screenshot polling for reliable recording duration

  Screencast only pushes frames on visual changes, producing short videos
  on static pages. Screenshot polling captures at a fixed 10fps interval
  regardless of page activity, guaranteeing duration matches wall-clock time.
  ffmpeg piped stdin architecture is preserved — no temp files.

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-15 08:04:27 -05:00
github-actions[bot] e78cc05cec chore: version packages (#819)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 04:36:01 -05:00
Chris Tate 25a152652a chore: add patch changeset for release (#818) 2026-03-15 04:28:01 -05:00
alexph-devandClaude Opus 4.6 5ea508a917 feat: add Brave Browser support to auto-connect CDP discovery (#817)
Brave Browser is Chromium-based and uses the same DevToolsActivePort
mechanism. Add its user-data-dir paths to get_chrome_user_data_dirs()
and its executable paths to find_chrome() on all three platforms
(macOS, Linux, Windows).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 04:21:51 -05:00
Chris Tateandctate 02d1a7ad7c Improve postinstall message to detect existing Chrome installations (#815)
* Improve postinstall message to detect existing Chrome installations

Previously, the npm postinstall script always recommended running `agent-browser install` to download Chrome for Testing, even when users already had a working Chrome installation on their system.

This change adds Chrome detection logic to the postinstall script that mirrors the runtime behavior:

- Checks for system Chrome installations on macOS, Linux, and Windows
- Shows a success message when Chrome is found, indicating it will be used automatically
- Only shows the `agent-browser install` warning when no Chrome is detected
- Provides platform-specific guidance (Linux `--with-deps` flag, `--executable-path` alternative)

The detection logic matches the existing Rust `find_chrome()` implementation to ensure consistency between postinstall messaging and runtime behavior.

Fixes #814

* Mention --cdp, --provider, --engine as alternatives in postinstall message

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-15 03:32:34 -05:00
github-actions[bot] c23ce448bd chore: version packages (#809)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 21:08:38 -05:00
Chris Tate fa91c22e50 chore: add patch changeset for release (#808) 2026-03-14 21:00:39 -05:00
Chris Tateandctate c07f43e242 fix: re-query accessibility tree when backend_node_id is stale (#806)
* fix: gracefully fall back to role/name lookup when backend_node_id is stale

When the DOM changes between snapshot and click (common with SPAs and
dynamic UIs), the stored backend_node_id becomes invalid. Previously,
DOM.getBoxModel and DOM.resolveNode failures propagated as hard errors,
bypassing the role/name fallback path entirely. Now these failures are
caught and the code falls through to a JS-based element lookup.

Also adds resolve_object_id_by_role_name so that resolve_element_object_id
has a fallback for ref-based lookups (previously it had none), and
improves the role matching JS to correctly map implicit ARIA roles
(e.g. <input type="submit"> → "button", <a href> → "link").

Closes #805

* test: add e2e regression test for stale ref click fallback (#805)

Verifies that clicking a ref whose backend_node_id has become stale
(because the DOM was replaced by JavaScript) falls back to role/name
lookup instead of failing with "Could not compute box model".

* fix: use accessibility tree for stale ref fallback instead of JS heuristic

Replace the hand-rolled JS role/name matching (getImplicitRole,
getAccessibleName) with a re-query of Accessibility.getFullAXTree —
the same data source that built the ref map during snapshot. This
guarantees role/name matching is identical to what was stored,
preventing silent wrong-element clicks from name computation
divergence (e.g. aria-labelledby, <label for>, alt text).

Matches v0.19.0 (Playwright) behavior where getByRole always
re-queried the live accessibility tree.

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-14 20:54:08 -05:00
github-actions[bot] 5f8e993602 chore: version packages (#804)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 18:23:38 -05:00
Chris Tate fc091d294d chore: add patch changeset for release (#803) 2026-03-14 18:15:39 -05:00
Chris Tateandctate c4f0f22ae9 fix: prevent daemon panic on broken stderr pipe during Chrome launch (#802)
Replace all `eprintln!` calls in daemon-context code with
`let _ = writeln!(std::io::stderr(), ...)` so that broken pipe errors
on stderr are silently ignored instead of panicking.

The CLI client spawns the daemon with piped stderr to capture startup
errors, then drops the pipe handle once the daemon is ready. Any
subsequent `eprintln!` in the daemon panics because Rust's `eprintln!`
macro internally unwraps the write result. This caused the reported
"failed printing to stderr: Broken pipe (os error 32)" panic during
Chrome launch on Linux.

Closes #799

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-14 18:08:22 -05:00
github-actions[bot] 23a8b19de1 chore: version packages (#801)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 17:27:38 -05:00
Chris Tate e2ebde261c chore: add patch changeset for release (#800) 2026-03-14 17:19:38 -05:00
Chris Tate 06842ea7c5 fix: remove unused pnpm setup from global-install CI job (#798)
The global-install job only uses npm (npm pack, npm install -g) but had
pnpm setup with cache enabled. On Windows, the pnpm store directory
doesn't exist since pnpm is never used, causing the Post Setup Node.js
cache step to fail with a path validation error.
2026-03-14 16:59:05 -05:00
Chris Tate a773863214 fix: handle broadcast channel lag instead of treating it as stream closure (#797)
The CDP event broadcast channel (capacity 256) can overflow on slow CI
runners when Chrome emits many events during navigation. Previously,
RecvError::Lagged was treated the same as RecvError::Closed, causing
spurious "Event stream closed" errors even though Chrome was still
running. Now all 5 event-receiving loops correctly continue on Lagged
instead of breaking.
2026-03-14 16:29:47 -05:00
github-actions[bot] f962c7a4c4 chore: version packages (#796)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 16:18:19 -05:00
github-actions[bot] 17d2785ca0 chore: version packages (#795)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 16:01:55 -05:00
Chris Tate d1ba208a50 fix: add --disable-dev-shm-usage for Chrome in CI/container environments (#794)
Chrome uses /dev/shm for shared memory, which is typically limited to
64MB on CI runners and containers. When Chrome exhausts this, it crashes
mid-session with "Event stream closed" errors. Auto-detect CI/container
environments and pass --disable-dev-shm-usage to use /tmp instead.
2026-03-14 15:58:44 -05:00
Chris Tate e365909d4f chore: add patch changeset for release (#793) 2026-03-14 15:54:10 -05:00
Chris Tateandctate d4b9004a6d fix: resolve snapshot hang over remote CDP (WSS) connections (#792)
The CDP WebSocket client had three issues causing snapshot to hang
indefinitely when connected to remote browsers via WSS:

1. Binary WebSocket frames were silently dropped — remote CDP proxies
   (Browserless, Browserbase, etc.) may send large responses like
   Accessibility.getFullAXTree as Binary frames instead of Text frames.

2. Default tungstenite size limits (16 MiB frame / 64 MiB message)
   could be exceeded by large accessibility tree responses, causing the
   WebSocket connection to error out and the reader task to die.

3. When the reader task died, pending commands waited for the full
   30-second timeout instead of failing immediately.

Fixes #788

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-14 15:46:08 -05:00
Chris Tate 529b8acfbe fix: retry Chrome launch up to 3 times on transient startup failures (#791)
Chrome occasionally crashes during startup on CI runners before
printing the DevTools URL, causing random e2e test failures across
different tests each run. Retry the launch with a 500ms delay to
handle these transient crashes.
2026-03-14 14:42:26 -05:00
github-actions[bot] fe3c1ddb48 chore: version packages (#790)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 14:06:03 -05:00
Chris Tate 944fa01247 chore: add patch changeset for release (#789) 2026-03-14 13:50:23 -05:00
Chris Tate da45d00b2a support consecutive --auto-connect commands (#786) 2026-03-14 13:43:38 -05:00
Chris Tate fbdae9b6ae fix: restore refs dict in --json snapshot output (#787)
- Restore the `refs` dictionary in `--json` snapshot output, matching the documented API contract
- The `refs` field was silently dropped during the Node.js to Rust rewrite (v0.20), causing consumers parsing `data.refs` for programmatic element interaction to receive no structured ref data

Fixes #785
2026-03-14 13:43:29 -05:00
Manolis TzanidakisandClaude Opus 4.6 388f19e1bb feat: add linux-musl (Alpine) builds for x64 and arm64 (#784)
* feat: add linux-musl (Alpine) builds for x64 and arm64

Add x86_64-unknown-linux-musl and aarch64-unknown-linux-musl targets to
the release workflow using cargo-zigbuild. Update the JS wrapper and
postinstall script to detect musl libc and select the correct binary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify isMusl() and add musl targets to build script

Address PR #784 review feedback:
- Remove redundant first try block in isMusl() (ldd --version always
  throws on musl, so only the `|| true` variant works)
- Add x86_64-unknown-linux-musl and aarch64-unknown-linux-musl targets
  to build-all-platforms.sh to stay in sync with the release workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 12:51:35 -05:00
jin.2andhyunjinee c4a40033e0 fix: correct e2e test assertions for diff_snapshot and domain_filter (#783)
- e2e_diff_snapshot: fix wrong field access (data.diff.identical →
  data.changed)
    and remove redundant assertion
  - e2e_domain_filter: set domain_filter after launch to avoid Fetch.enable
  deadlock

Co-authored-by: hyunjinee <leehj0110@kakao.com>
2026-03-14 12:07:46 -05:00
mikewong23571 1fa7949542 test: fix Chrome temp-dir cleanup test on Windows (#766) 2026-03-14 12:03:08 -05:00
github-actions[bot] 974d735af5 chore: version packages (#782)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-14 09:46:03 -05:00
Chris Tate bd05917f20 chore: add patch changeset for release (#781) 2026-03-14 09:36:36 -05:00
44ce24fb48 fix: use VP9 codec for webm recording output (#779)
* fix: use VP9 codec for webm recording output

The recording command hardcoded libx264 (H.264) which is incompatible
with the WebM container format. WebM only supports VP8/VP9/AV1 codecs,
causing ffmpeg to fail when users specify a .webm output file.

Select codec based on output file extension: libvpx-vp9 for .webm,
libx264 for other formats.

Fixes #778

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use CRF mode for VP9 webm encoding

Switch from bitrate target (-b:v 2M) to constant quality mode (-crf 30),
which is the standard approach for screen recording (used by Puppeteer
and recommended by ffmpeg VP9 guide). CRF adapts bitrate to scene
complexity for more consistent quality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add -b:v 0 for true constant quality VP9 encoding

Without -b:v 0, libvpx-vp9 uses its default bitrate target alongside
-crf, resulting in constrained quality mode instead of true constant
quality mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pad video dimensions to even numbers for h264 compatibility

libx264 requires width and height to be divisible by 2, but CDP
screencast can capture frames with odd dimensions (e.g. 1280x577).
Add pad filter to ensure even dimensions for all codecs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 09:27:33 -05:00
67986adffc fix: correct misleading SIGPIPE comment (#776)
The comment said "Ignore SIGPIPE" but the code actually resets SIGPIPE
to SIG_DFL (default behavior = process termination), not SIG_IGN (ignore).
Updated the comment to accurately describe what the code does and why.

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 06:33:20 -05:00
Samuel Reed 790123f9af fix: accept integer nodeId/childIds in AX tree for Lightpanda compatibility (#775) 2026-03-14 06:32:35 -05:00
github-actions[bot] 99c732c188 chore: version packages (#772)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-13 20:33:33 -05:00
Chris Tate c7ad5ff66a fix: repair CI failures from stale lockfile and Chrome sandbox on GHA runners (#771)
Regenerate pnpm-lock.yaml to match the cleaned-up package.json (only
@changesets/cli remains). Add CI environment detection to
should_disable_sandbox() so Chrome launches with --no-sandbox on GitHub
Actions runners where AppArmor blocks unprivileged user namespaces.
2026-03-13 20:23:13 -05:00
Chris Tate f8482f3533 fix (#770) 2026-03-13 20:16:29 -05:00
Chris Tate bdfcc4ee2d publish to cargo (#769) 2026-03-13 20:15:41 -05:00
Chris Tate 235fa88dc6 prepare v0.20 (#768) 2026-03-13 20:11:30 -05:00
Chris Tate 8e43469c8b full native (#754)
* full native

* fix: apply cargo fmt formatting

* fix: prevent zip path traversal in Chromium installer

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

* improvements

* fix: apply cargo fmt formatting

* benchmarks

* bench

* updates

* fixes
2026-03-13 19:59:21 -05:00
mikewong23571 d4b948c1d4 test: fix flaky test_launch_options_from_env_defaults due to missing EnvGuard (#763)
The test was reading AGENT_BROWSER_HEADED without holding ENV_MUTEX,
causing a race with test_launch_options_from_env_headed_flag when
tests run in parallel.
2026-03-13 12:18:40 -05:00
mikewong23571 d40fd4009d fix: harden Lightpanda startup timeouts (#762) 2026-03-13 11:36:51 -05:00
Selman 056024b447 fix: Lightpanda engine launch with release binaries (#760)
Three issues prevented --engine lightpanda from working with official
Lightpanda release builds:

1. Missing --log_level info: Lightpanda release builds default to
   log_level=warn, which suppresses the info-level "server running"
   startup message. wait_for_address() blocks forever reading an empty
   stderr pipe. Pass --log_level info explicitly.

2. --timeout 0 means instant disconnect: Lightpanda interprets 0 as
   "timeout after 0ms", not "no timeout". Use 604800 (1 week, the
   documented maximum) instead.

3. extract_address only matched pretty format: Release builds use
   logfmt (address=HOST:PORT without spaces), but the parser only
   matched the pretty format (address = HOST:PORT with spaces). Handle
   both formats.
2026-03-13 10:40:19 -05:00
mikewong23571 3a2e2796c8 fix: correct storage local key lookup parsing and text output (#761) 2026-03-13 09:45:00 -05:00
Hyunjin Lee f426860c04 fix: narrow "not found" pattern in to_ai_friendly_error to avoid catching non-element errors (#759)
* fix: narrow "not found" pattern in to_ai_friendly_error to avoid catching
  non-element errors

  Change `contains("not found")` to `contains("element not found")` so that
  connection/state errors like "Browser not found" pass through unchanged
  instead of being incorrectly mapped to "Element not found" message.

* remove comment

* fmt

* test: use real project error message in non-element not found test
2026-03-13 09:43:38 -05:00
QuietyAwe ea9d456341 fix: respect --headed false flag in CLI (#757)
When user explicitly sets --headed false, the CLI was ignoring this
flag because the launch condition only checked if flags.headed was
true. This meant that --headed false would not trigger a launch
command, and subsequent commands would auto-launch with default
headless=true.

The fix adds a cli_headed flag to track when the user explicitly
sets --headed (regardless of value), and includes this in the
launch condition check.

Fixes #743
2026-03-13 09:42:59 -05:00
github-actions[bot] e02bc1a10c chore: version packages (#756)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-13 03:59:13 -05:00
Chris Tate 56bb92bfe1 prepare v0.19 (#755) 2026-03-13 03:48:00 -05:00
Chris Tate 087600e50e Fix linting and formatting issues to resolve CI build failures (#752)
This PR fixes CI build failures by addressing code formatting and linting issues that were causing the builds to fail.

**Changes made:**

1. **Rust formatting fixes in `cli/src/commands.rs`:**
   - Removed unnecessary multi-line formatting for clipboard operations
   - Applied consistent single-line formatting for return statements
   - Fixed line length and formatting for the `test_wait_text_with_timeout` test function

2. **TypeScript fixes in `src/actions.ts`:**
   - Fixed `waitForFunction` usage in the `handleWait` function by replacing the function parameter approach with a string-based implementation
   - Properly escaped the text parameter using `JSON.stringify` to prevent potential injection issues

These changes ensure the code passes linting checks (clippy for Rust, ESLint for TypeScript) and formatting validation (rustfmt, prettier) that are enforced in the CI pipeline.

Fixes #751
2026-03-13 03:31:49 -05:00
Chris Tate a673a77c4e feat: add screenshot output config, clipboard CLI commands, and fix wait --text native path (#749)
* feat: add screenshot output config, clipboard CLI commands, and fix wait --text native path

## Summary

- Add `--screenshot-dir`, `--screenshot-quality`, and `--screenshot-format` CLI flags (with corresponding `AGENT_BROWSER_SCREENSHOT_DIR`, `AGENT_BROWSER_SCREENSHOT_QUALITY`, `AGENT_BROWSER_SCREENSHOT_FORMAT` env vars) so users can configure where and how screenshots are saved without specifying a full path every time
- Add `clipboard read`, `clipboard write <text>`, `clipboard copy`, and `clipboard paste` CLI commands, exposing the existing protocol-level clipboard handlers that were previously only accessible via JSON-RPC
- Fix `wait --text` in native mode: the CLI was emitting `selector: "text=..."` (a Playwright-style locator) which native's `querySelector` can't handle. Now emits a `text` field that correctly hits the native `wait_for_text` polling path
- Add native clipboard `copy` and `paste` support via CDP `Input.dispatchKeyEvent`, and a `write` operation to the Node.js handler

* fix: resolve CI failures in Rust formatting and TypeScript typecheck

Use string-based page.evaluate for clipboard writeText to avoid
referencing `navigator` in Node.js compilation context. Run cargo fmt
to fix formatting in commands.rs and screenshot.rs.

* fix: clipboard write captures full multi-word text

Use rest[1..].join(" ") instead of rest.get(1) so unquoted multi-word
input like `clipboard write hello world` sends the full string rather
than silently dropping everything after the first word.

* improvements

* fixes

* improvements

* improvements
2026-03-13 02:58:30 -05:00
mikewong23571andClaude Sonnet 4.6 1129a3e7fc fix: restore BrowserManager.navigate() and package entry point (#748)
Root cause: package.json `main` pointed at `dist/daemon.js` (the
internal daemon process), so programmatic consumers of the package
received the daemon module instead of a usable API. Additionally,
`BrowserManager.launch()` required IPC-only fields (`id`, `action`),
and `navigate()` existed only as a private function inside actions.ts.

Changes:
- Add src/index.ts as the public package entry point
- Add BrowserLaunchOptions type (Pick<LaunchCommand> minus id/action/engine)
  to decouple the programmatic API from the IPC wire protocol
- Change launch() signature from LaunchCommand to BrowserLaunchOptions
- Add BrowserManager.navigate(url, options?) — consolidates domain check,
  scoped-header setup, and page.goto() into one reusable method; auto-
  recovers a new page when all pages have been closed (stale session)
- Add BrowserManager.getUrl() and getTitle() convenience methods
- Update package.json: main → ./dist/index.js, add types and exports["."]
- Add tests: navigate() with headers, waitUntil, allowedDomains blocking,
  allowedDomains allow, non-http(s) scheme blocking, getUrl/getTitle, and
  compile-time type assertions verifying the public entry exports the right
  API surface (direct repro of #307)

Fixes #307

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:55:47 -05:00
Chris Tate 640d259130 Fix extensions not being loaded from config.json (#750)
Fix issue where Chrome extensions specified in the `extensions` field of `config.json` were not being loaded when launching the browser.

## Problem
Extensions configured via the `extensions` field in `config.json` were not being passed to the Chrome browser launch command, causing them to be ignored.

## Changes
- Added `!flags.extensions.is_empty()` to the launch trigger condition to ensure browser launch is triggered when extensions are configured
- Added extensions to the launch command JSON payload so they are properly passed to the browser

Fixes #726
2026-03-13 02:42:39 -05:00
Chris Tateandctate 1327856889 feat: add browserless provider integration to native browser implementation (#746)
* feat: add browserless provider integration to native browser implementation

This PR adds support for the Browserless provider to the native browser implementation, expanding the available remote browser providers from 3 to 4.

## Changes Made

- **Added `connect_browserless()` function**: Implements session creation with Browserless API using environment variables for configuration
- **Updated provider routing**: Added "browserless" case to the main provider switch statement
- **Added session cleanup**: Implemented proper session termination using the stop URL returned by Browserless
- **Updated documentation**: Modified comments and error messages to include Browserless in the supported provider list
- **Environment variable support**: Added support for configurable Browserless settings including API key, URL, browser type, TTL, and stealth mode

## Implementation Details

- Uses standard Browserless session API with POST to create sessions and DELETE to terminate
- Supports both chromium and chrome browser types with validation
- Includes proper error handling for API failures and missing configuration
- Stores the stop URL as session_id for cleanup purposes
- Follows the existing provider pattern for consistency

Fixes #744

* fix: URL-encode API key in browserless session request

Use reqwest's .query() method instead of string-formatting the token
directly into the URL, matching the Node.js implementation's use of
encodeURIComponent. Prevents malformed URLs if the API key contains
special characters.

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-13 01:38:57 -05:00
Chris Tate a3dcf3fe60 fix scroll on page load (#747) 2026-03-13 01:38:39 -05:00
Chris Tateandctate 870876b5c1 Fix HTML retrieval by using browser.getLocator() for selector operations (#745)
* Fix HTML retrieval by using browser.getLocator() for selector operations

This PR fixes an issue where HTML content retrieval was not working properly when using selectors.

**Problem:**
The `get html` command and other selector-based operations were failing because they were using `page.locator()` directly instead of the browser manager's locator method.

**Changes:**
- Updated `handleContent()` to use `browser.getLocator()` instead of `page.locator()` for HTML retrieval with selectors
- Applied the same fix to other affected functions: `handleCount()`, `handleBoundingBox()`, `handleInnerText()`, `handleInnerHtml()`, and `handleSetValue()`
- Ensures consistent locator handling across all selector-based operations

**Implementation Details:**
The fix replaces direct `page.locator(command.selector)` calls with `browser.getLocator(command.selector)` to ensure proper element selection and interaction through the browser manager's abstraction layer.

Fixes #735

* Fix remaining page.locator() calls to use browser.getLocator()

Apply the same fix to all remaining functions that were using
page.locator(command.selector) directly instead of going through
browser.getLocator(): handleWheel, handleHighlight, handleClear,
handleSelectAll, handleDispatch, handleNth, handleMultiSelect,
and handleDiffScreenshot.

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-13 01:11:13 -05:00
Joel Griffith b9a24df40f feat: Add browserless.io as a browser provider (#502)
* feat: Add browserless as a hosted option + boolean env-parsing utility

* Add ensureDomainFilter, sanitizeExistingPage and move parseBooleanParam

* Add docs in relevant places, fix utils, rename of API env var

* Update readme

* Fix env variable name in readme

* Cleanup session stop urls when errors happen

* Fix browserlessStopUrl not being assigned in happy path
2026-03-13 00:45:02 -05:00
Chris Tate fb7185d860 ci: switch from windows-latest-8-cores to windows-latest runner (#742)
Resolves CI slowdown issues caused by limited availability of Windows containers with 8 cores by switching to the standard Windows runner image.

## Changes Made

- Updated `rust-cross` job to use `windows-latest` instead of `windows-latest-8-cores`
- Updated `windows-integration` job to use `windows-latest` instead of `windows-latest-8-cores`  
- Updated `global-install` job matrix to use `windows-latest` instead of `windows-latest-8-cores`

This change trades some performance for better availability and faster CI queue times, as the standard Windows runners have much better availability than the 8-core variant.

Fixes #741
2026-03-12 14:53:28 -05:00
github-actions[bot] a66c5960f2 chore: version packages (#740)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-12 14:41:00 -05:00
d019c09bbc feat: add idle timeout to daemon to prevent orphaned Chrome processes (#722)
* feat: add idle timeout to daemon to prevent orphaned Chrome processes

The daemon persists indefinitely after browser sessions are used,
leaving orphaned Chromium processes consuming memory and CPU.

Add a configurable idle timeout (default 15 minutes) that shuts down
the daemon when no commands arrive. Resets on every incoming command,
so active sessions are unaffected.

Set AGENT_BROWSER_IDLE_TIMEOUT_MS=0 to disable (preserves old behavior).

Fixes #721

* fix: save session state before shutdown to prevent silent data loss

The shutdown() function (used by idle timeout, SIGINT, SIGTERM, SIGHUP)
previously closed the browser without saving state, unlike the explicit
`close` command which calls saveStateToFile(). This meant idle timeouts
silently destroyed cookies, localStorage, and login sessions.

Now shutdown() mirrors the close command's auto-save behavior: it calls
saveStateToFile() before manager.close(), preserving session state to
disk. This makes idle timeout functionally equivalent to an explicit
close — users returning after an idle shutdown get their state restored.

Addresses review feedback on #722 by @ctate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Max Stoel <maxalerator@hotmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:39:25 -05:00
mikewong23571 d4f7fbc718 fix: align native daemon port hash with client on Windows (#734)
The client (connection.rs) and native daemon (native/daemon.rs) used
different get_port_for_session() implementations on Windows:

- Client:  i32, .chars(), djb2  — (hash << 5) - hash + c
- Daemon:  i64, .bytes(), Java hashCode — hash * 31 + b

For session name "default", client computes port 50838 while the
daemon binds on 51174, causing a 5-second timeout and startup failure.

Fix: align native/daemon.rs to use the identical djb2 algorithm from
connection.rs (i32, chars, djb2), so both sides agree on the port.

Unix is unaffected (uses Unix domain sockets, no port hashing).

Tests: add port hash regression tests to all three implementations
(native/daemon.rs, connection.rs, daemon.ts) to prevent future drift.

Fixes #705
2026-03-12 14:15:35 -05:00
吴洪磊andHonglei Wu 2fb2a51c82 fix: update handleGetText to use innerText for improved text retrieval (#729)
The handleGetText function now retrieves text using innerText, falling back to textContent if innerText is not available. This change enhances the accuracy of text extraction from elements.

Co-authored-by: Honglei Wu <honglei.wu@shopee.com>
2026-03-12 13:59:09 -05:00
mikewong23571 c562ef5bb7 fix: allow newTab() in persistent context (--extension/--profile) mode (#731)
When launched with --extension or --profile, launchPersistentContext()
is used which sets isPersistentContext=true but leaves this.browser as
null. The guard in newTab() checked !this.browser, causing a false
"Browser not launched" error even though the browser was running.

Replace !this.browser with !this.isLaunched(), which already accounts
for both launch paths (browser !== null || isPersistentContext).

Also improve the error message in newWindow() to clarify that it is
not supported in persistent context mode, since it requires a Browser
object to create a new context.

Fixes #411
2026-03-12 13:55:55 -05:00
01172eaa44 fix: isolate getEncryptionKey tests from local filesystem (#737)
* fix: isolate getEncryptionKey tests from local filesystem

Tests for getEncryptionKey() failed on machines where
~/.agent-browser/.encryption-key existed, because the file-based
fallback was not mocked out. Mock node:fs to isolate both env var
and key file paths, and add missing tests for the file fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: clean up fs mock naming in encryption tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:45:05 -05:00
Chris Tate 942b8cd8ee prepare v0.18.0 (#738) 2026-03-12 12:19:54 -05:00
Chris Tate 315d191606 inspect (#736)
* inspect

* fixes

* improvements

* fixes

* fixes

* improvements

* fix null cdp url

* fix rust reader loop

* improvements

* improvements

* fixes
2026-03-12 12:01:40 -05:00
Chris Tate f2d4089284 auth docs (#730)
* add docs

* note

* format
2026-03-12 00:54:19 -05:00
Derek cc82dd137c Remove BROWSERBASE_PROJECT_ID requirement (#625)
* Remove BROWSERBASE_PROJECT_ID requirement

The Browserbase API no longer requires a project ID to create sessions —
it is inferred from the API key. Remove the env var requirement from both
the TypeScript daemon and Rust CLI, and update docs accordingly.

* Remove unnecessary Content-Type header since no body is sent
2026-03-11 22:40:30 -05:00
Hideeeeandhidezhao 18c3112dbd feat: Support for screenshot annotate on rust (#706)
Co-authored-by: hidezhao <hidezhao@tencent.com>
2026-03-11 22:25:28 -05:00
WenruiUteandClaude Opus 4.6 89f9c97ac2 fix: use correct Browserbase API to release sessions (#707)
Browserbase has no DELETE endpoint for sessions. The correct API is
POST /v1/sessions/:id with body { status: "REQUEST_RELEASE" }. The old
DELETE call returned an error that was silently swallowed, causing every
session to leak until the 30-min idle timeout.

Fixed in both Node.js (src/browser.ts) and native Rust
(cli/src/native/providers.rs) paths.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:09:58 -05:00
Iddo Gino db3d23d496 fix: use getDefaultTimeout() in CDP connect paths instead of hardcoded 10s (#704)
The connectViaCDP and connectToBrowserbase methods hardcoded
context.setDefaultTimeout(10000), ignoring the AGENT_BROWSER_DEFAULT_TIMEOUT
env var. This made page.goto time out after 10s on CDP connections even when
the env var was set to a higher value. Now both paths use getDefaultTimeout()
like all other connection modes.

Fixes #703
2026-03-11 22:08:31 -05:00
78c9aef3c9 fix: sanitize lone Unicode surrogates using toWellFormed() (#720)
* fix: sanitize lone Unicode surrogates in snapshot and response serialization (#635)

Pages with emoji/special characters can contain lone surrogates (e.g. \uD800
without a matching \uDC00-\uDFFF), causing serde_json to fail with
"unexpected end of hex escape" when parsing the JSON response.

- Add sanitizeSurrogates() to replace lone surrogates with U+FFFD in
  ariaSnapshot output
- Add sanitizeJsonSurrogates() safety net in serializeResponse for other
  response fields (page.title, page.content, etc.)
- Add tests for both sanitization paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove trivial "no surrogates unchanged" test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retry flaky Rust test

* refactor: remove unnecessary sanitizeSurrogates from snapshot.ts

Chromium's ariaSnapshot() converts lone surrogates to literal text
(e.g. the 6-char string "\ud800"), not actual surrogate code points.
The real fix is sanitizeJsonSurrogates() in protocol.ts which handles
eval and other response paths where actual surrogates appear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use toWellFormed() instead of regex for lone surrogate sanitization

Upgrade tsconfig target/lib from ES2022 to ES2024 and replace the
manual regex-based surrogate sanitization with String.prototype.toWellFormed().
This is simpler, more readable, and relies on the standard API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove trivial no-surrogate test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:48:26 -05:00
417428463b Fix CDP connection failure on IPv6-first systems (#717)
Use 127.0.0.1 instead of localhost when constructing CDP URL from port
number, since Chrome only binds to IPv4. This prevents connection
failures on systems like Ubuntu 24.04 where localhost resolves to ::1.

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 04:15:21 -05:00
Walter Cheng def2fd90fa fix: inherit current viewport for recordings (#718) 2026-03-11 00:45:36 -05:00
Chris Tate d678058206 docs: Add missing vercel-sandbox skill and fix electron section (#713)
Updates the skills documentation to include the missing `vercel-sandbox` skill that was missing from both the available skills list and installation commands.

## Changes
- Added `vercel-sandbox` skill to the Available Skills list with description
- Added installation command for `vercel-sandbox` skill
- Added dedicated section for `vercel-sandbox` with key features and usage details
- Removed duplicate paragraph in the electron section

The `vercel-sandbox` skill enables running agent-browser + headless Chrome inside ephemeral Vercel Sandbox microVMs with features like snapshot startup, persistent workflows, and automatic OIDC authentication.

Fixes #712
2026-03-10 08:59:36 -05:00
Chris Tate c2794232ee fix deployment for example (#701) 2026-03-09 17:41:04 -05:00
Chris Tate f6c83e410b fix link (#700) 2026-03-09 17:36:01 -05:00
Umayr SheikandClaude Opus 4.6 9f41545216 docs: add viewport documentation to SKILL.md (#697)
The `set viewport` command is fully implemented but missing from the
agent-facing skill guide. Agents relying on SKILL.md would not know
they could resize the viewport, test responsive layouts, or use retina
scaling.

- Add viewport commands to Essential Commands section
- Add Viewport & Responsive Testing pattern with practical examples

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:34:06 -05:00
Chris Tate 82386b1c60 fix links (#699) 2026-03-09 17:33:40 -05:00
Chris Tate c309535691 sandbox docs (#698)
* sandbox docs

* format
2026-03-09 17:09:05 -05:00
Mason Williams d0651f14bc Make KERNEL_API_KEY optional for external credential injection (#687)
* Make KERNEL_API_KEY optional for external credential injection

When running inside environments with external credential injection
(e.g. Vercel Sandbox credentials brokering), the KERNEL_API_KEY env
var can be omitted. The network layer injects the Authorization header
on outbound requests to api.onkernel.com, so the API key never needs
to exist inside the sandbox.

If KERNEL_API_KEY is set, it's used as before. If not, requests are
sent without an Authorization header, allowing external injection.
Without either, the Kernel API returns 401.

Made-with: Cursor

* Make KERNEL_API_KEY optional in native Rust daemon too

Applies the same change to the native Rust connect_kernel() function
so both the Node.js and native code paths support external credential
injection.

Made-with: Cursor

* Address review feedback: fix type errors, cargo fmt, always send cleanup DELETE

- Fix kernelApiKey assignment: use ?? null for undefined -> null
- Fix closeKernelSession signature: accept string | undefined
- Always send DELETE on cleanup even without local API key (external
  injection covers it)
- Run cargo fmt on Rust code

Made-with: Cursor
2026-03-09 17:04:58 -05:00
Chris Tate 5bf9fedd58 fix environments demo (#696)
* fix

* fixes

* fixes

* update docs

* fixes

* fixes

* sandbox tokens

* better logging
2026-03-09 17:00:30 -05:00
Chris Tate c0a525c9e4 rate limits for demo (#695) 2026-03-09 15:43:25 -05:00
Chris Tateandctate cc3c70dc86 next.js example (#694)
* next.js guide

* better

* shadcn

* fixes

* fix: correct screenshot test assertion to check path instead of base64

The daemon returns { path: savePath } for screenshot commands, not base64.

* fix: cross-platform Chrome detection and gitignore hardening

- Replace hardcoded macOS Chrome path with findLocalChrome() that
  searches common paths on macOS, Linux, and WSL, with a clear error
  message when no Chrome is found.
- Add .env and .env*.local to .gitignore to prevent accidental
  secret commits.

* fix: correct Vercel deploy button repo URL to vercel-labs/agent-browser

* clean up

* demo

* next page

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-09 15:24:44 -05:00
pixqc 3649787268 fix security docs url in readme (#690) 2026-03-09 15:13:21 -05:00
github-actions[bot] 00a0e0707e chore: version packages (#693)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-09 12:40:37 -05:00
Chris Tate 94cd888ecb chore: add patch changeset for release (#692) 2026-03-09 11:24:04 -05:00
Chris Tate 644a4f5b63 add scale factor to set viewport for retina screenshots (#691)
* device scale

* fix node.js daemon

* fix cargo fmt formatting for scale factor code

* fixes
2026-03-09 11:10:22 -05:00
a0bd0c2f0f Add webview support for Electron apps in native mode (#671)
* Add webview support for Electron apps in native mode

Fixes #580

* Fix cargo fmt violations in actions.rs and browser.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert unrelated refactors, keep only webview support changes

- Restore is_none_or (was changed to map_or unnecessarily)
- Restore single-if WebDriver check (was split into nested ifs)
- Restore simple needs_relaunch logic (was expanded into 4 branches)
- Restore find_frame signature (unused selector param was added)
- Restore flat download handler condition (was nested unnecessarily)
- Restore wait_or_kill and graceful close() to preserve cookie flushing

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:41:40 -05:00
github-actions[bot] 2bab729f26 chore: version packages (#684)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-08 13:24:22 -05:00
Chris Tate 94521e7a8c chore: add minor changeset for release (#683) 2026-03-08 11:22:56 -05:00
d9387aae58 ci: add clippy check to Rust CI workflow (#675)
Add `cargo clippy -- -D warnings` step to the Rust CI job so that
clippy warnings fail the build. Also fix the one new lint
(`unnecessary_map_or`) introduced in the current stable clippy.

Fixes #672

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:41:56 -06:00
aba2353112 Fix clippy warnings across CLI codebase (#654)
* Fix clippy warnings across CLI codebase

Fixes #653

* Fix remaining items_after_test_module clippy warnings

Move functions defined after `mod tests` blocks to before the test
modules in recording.rs and webdriver/client.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:18:51 -06:00
68cebe5192 Fix Chrome extensions not loading by forcing headed mode when extensions present (#652)
* Fix Chrome extensions not loading by forcing headed mode when extensions present

Fixes #640

* Restore wait_or_kill() and add tests for headless+extensions logic

Restore the ChromeProcess::wait_or_kill() method that was accidentally
removed. It is still referenced by BrowserProcess in browser.rs and is
needed for graceful shutdown / cookie persistence (PR #650).

Add unit tests verifying --headless=new is omitted when extensions are
present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix window-size leak in headed+extensions mode and remove unused channel option

- Skip --window-size=1280,720 when extensions force headed mode (native)
- Remove unexplained channel: 'chromium' from extensions launch path (TS)
- Add window-size assertion to existing extension test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:58:18 -06:00
Qiaochu Hu f262ff1bf3 docs: improve snapshot usage guidance and add reproducibility check (#630)
Fixes #566: Clarify snapshot vs snapshot -i usage
- Add guidance that snapshot -i is for clickable/fillable elements
- Add guidance that snapshot (no flag) is for reading page content

Fixes #565: Add reproducibility verification before collecting evidence
- Add guidance to verify issues are reproducible before recording video
- Prevent wasting turns on false positives
2026-03-06 14:31:04 -06:00
Li Yang 788ad0e61f chore: add cargo fmt check to Rust CI and fix existing violations (#620)
TypeScript CI has prettier --check but Rust CI only runs cargo test.
Add cargo fmt --check to catch formatting issues early, and fix the
8 pre-existing formatting violations on main.
2026-03-06 13:32:26 -06:00
Chris Tate b7e7a2548e fix: persist auth cookies on close in native mode (#650) 2026-03-06 12:46:03 -06:00
Chris Tate 492830accb Fix: Suppress Google Translate bar in native headless mode (#649)
Fixes #617
2026-03-06 12:36:32 -06:00
Chris Tate 7acde7e29a fix: native auth login fails due to incompatible encryption format (#648)
* fix: native auth login fails due to incompatible encryption format

* fixes

* fixes
2026-03-06 12:31:12 -06:00
Chris Tate 0da54c7038 lightpanda (#646)
* lightpanda

* lightpanda benchmarks

* improvements

* fixes

* improvements
2026-03-06 11:16:37 -06:00
Chris Tate 36c2e06f89 add benchmarks (#637) 2026-03-06 00:46:59 -06:00
layla 8f6ad817f1 Fix dialog dismiss command parsing (#605) 2026-03-04 17:14:12 -06:00
Li Yang de5ea1d8cf fix: use reqwest for CDP port discovery instead of broken hand-rolled HTTP client (#619)
reqwest_get_string() was hand-rolling HTTP/1.1 over raw TCP despite reqwest
being an existing dependency. The hand-rolled implementation had two bugs:

1. URL path parsing: url.find('/') matched the first '/' in 'http://',
   producing path '//127.0.0.1:9222/json/version' instead of '/json/version'

2. read_to_end() hangs: Chrome's DevTools HTTP server ignores Connection: close
   and keeps the socket open, so read_to_end() waits for EOF that never comes

This caused 'agent-browser --cdp <port>' to always timeout when AGENT_BROWSER_NATIVE=1.

Fix: replace 49 lines of broken TCP code with reqwest::get(), which was
already in Cargo.toml.
2026-03-04 16:32:01 -06:00
Chris Tate 139dd0ec5a fix: surface daemon startup errors instead of opaque timeout message (#614)
When the daemon process crashes during startup (e.g., missing
Playwright), stderr was discarded via Stdio::null(), so users only
saw "Daemon failed to start (port: ...)" with no diagnostic info.

Now captures daemon stderr via Stdio::piped() and detects early process
exit with try_wait() during the startup polling loop. If the daemon
crashes, the actual error from stderr is shown to the user.

Also forwards --debug flag to the daemon process as AGENT_BROWSER_DEBUG
so debug logging works end-to-end.

Closes #56
2026-03-04 01:05:33 -06:00
github-actions[bot] 794a77e26e chore: version packages (#613)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 01:02:34 -06:00
Chris Tate 7d2c8957ac chore: add patch changeset for release (#612) 2026-03-04 00:30:54 -06:00
Li Yang eaa968e229 fix: suppress spurious --native warning when set via env var (#611)
* fix: suppress spurious --native warning when set via env var

When AGENT_BROWSER_NATIVE=1 is set via environment variable, every
command after the first would warn:

  ⚠ --native ignored: daemon already running.

This is a false positive — the daemon was already spawned in native
mode and inherited the env var. The warning should only fire when
--native is explicitly passed on the CLI to an already-running daemon.

Add cli_native flag (consistent with existing cli_* pattern) to
distinguish CLI origin from env var origin.

* fix: add flag to test cfg

* fix: cli_native should track flag presence, not value

--native false on CLI should still warn when daemon is already
running, since the user is explicitly trying to change the mode.
2026-03-04 00:17:14 -06:00
github-actions[bot] 7edc5d596c chore: version packages (#610)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 23:56:46 -06:00
Chris Tate 01ac5574d4 chore: add patch changeset for release (#609) 2026-03-03 23:07:56 -06:00
Chris Tate e5fd26eb9e headed mode (#607)
* headed mode

* fixes

* fixes

* docs

* fixes

* fixes

* fixes
2026-03-03 22:34:07 -06:00
github-actions[bot] a493d02c66 chore: version packages (#604)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 17:59:02 -06:00
Chris Tate c4180c8cb1 chore: add patch changeset for release (#603) 2026-03-03 17:51:29 -06:00
Chris Tate 56260f68b0 Native: auto-detect sandbox/container environments for Chrome launch (#602)
Fixes #600

Three improvements to `--native` Chrome launching:

- `find_chrome()` now falls back to Playwright's browser cache (`~/.cache/ms-playwright/`) when no system Chrome is found
- Auto-detect containers/VMs (root, Docker, Podman, cgroups) and inject `--no-sandbox`
- Chrome stderr is now captured and included in launch error messages, with a hint when sandbox errors are detected
2026-03-03 17:45:23 -06:00
Chris Tate 324a9e4e0c windows 8 cores (#599)
* windows 8 cores

* add workflow dispatch
2026-03-03 17:26:22 -06:00
Chris Tate 7f42eed031 faster ci (#598) 2026-03-03 16:45:57 -06:00
github-actions[bot] c10981413f chore: version packages (#597)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 16:18:08 -06:00
Chris Tate 05018b309a prepare v0.16.0 (#596) 2026-03-03 16:09:39 -06:00
Chris Tate 9d0454d229 fix: switch from native-tls to rustls for cross-compilation (#595)
The native PR introduced tokio-tungstenite and reqwest with native-tls,
which depends on openssl-sys (C library). This breaks the release
workflow's cargo-zigbuild cross-compilation on Linux because zig's C
compiler can't find the system OpenSSL headers.

Switch to rustls (pure Rust TLS) which has zero C dependencies and
cross-compiles trivially. Also shrinks the dependency tree.
2026-03-03 15:45:20 -06:00
Chris Tate 51f5fa484c native (#594)
* Native Rust rewrite of agent-browser daemon

Single-binary Rust implementation replacing the Node.js/Playwright daemon
with direct CDP (Chrome DevTools Protocol) communication. Includes full
command parity, WebDriver/Safari/iOS backend routing, request tracking,
frame context management, CDP protocol codegen, and comprehensive tests.

* improvements

* fix ci

* fixes

* faster builds
2026-03-03 15:15:57 -06:00
github-actions[bot] 857c0b25df chore: version packages (#591)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 08:21:58 -06:00
Chris Tate 62241b50e9 chore: add patch changeset for release (#589) 2026-03-03 08:08:02 -06:00
Chris Tate c6a33b6338 fix(windows): resolve daemon startup failures and Git Bash compatibility (#582)
* fix(windows): resolve daemon startup failures and Git Bash compatibility

Three root causes behind 27 open Windows issues:

1. Path::canonicalize() returns \\?\ prefixed paths on Windows that
   Node.js cannot parse, preventing daemon startup. Strip the prefix
   before passing to Node. (fixes #522, #390, #56, #25, #37, #89)

2. Git Bash/MSYS2 translates Unix-style paths and resolves node to
   a shell wrapper script. Use node.exe explicitly and set
   MSYS_NO_PATHCONV/MSYS2_ARG_CONV_EXCL to prevent argument mangling.
   (fixes #148, #108, #171)

3. postinstall fixWindowsShims() hardcoded x64 arch and did not verify
   the native binary exists before rewriting shims. Now detects arch
   dynamically and validates the binary path. (fixes #262)

Also:
- Error messages now show TCP port on Windows instead of Unix socket path
- Windows CI expanded to test full daemon lifecycle (open, snapshot, close)

* fix(windows): strip \\?\ prefix in auth-cli path (fixes #579)

Same canonicalize() issue as the daemon spawn path, but in
run_auth_cli() which passes the script path to Node.js.
2026-03-03 08:00:14 -06:00
github-actions[bot] d97e2016f5 chore: version packages (#585)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 17:16:52 -06:00
Chris Tate 6aea316c82 chore: add patch changeset for release (#583) 2026-03-02 17:10:32 -06:00
Chris Tate c7fa10cb1b remove skill creator (#581) 2026-03-02 16:26:19 -06:00
Giulio Leone b304a4188c fix: correct misleading output for cookies clear and tab close (#556) (#563)
Bug 1: `cookies clear` printed 'Request log cleared' instead of 'Cookies cleared'
because the output handler matched the generic `{ cleared: true }` response shape
without checking the action context. Now uses the `action` parameter to distinguish
`cookies_clear` from `requests --clear`.

Bug 2: `tab close` printed 'Browser closed' instead of 'Tab closed' because the
output handler matched the generic `{ closed: ... }` response shape without checking
the action context. Now uses the `action` parameter to distinguish `tab_close` from
`close` (full browser close).

Closes #556
2026-03-01 12:23:23 -06:00
neilmixandClaude Opus 4.6 e912f541f2 fix: treat EPERM from kill(pid, 0) as "process exists" in daemon liveness checks (#564)
Per POSIX, kill(pid, 0) returns EPERM when the process exists but the
caller lacks permission to signal it, and ESRCH when it does not exist.
The daemon liveness checks in both the Rust CLI and TypeScript daemon
treated any kill failure as "not running", which is incorrect when
running inside a macOS sandbox that restricts signal delivery to
(target self). This caused the CLI to delete the real daemon's socket
and PID files, then spawn a duplicate daemon.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:12:37 -06:00
7238b7da4c fix: resolve unnamed element refs matching multiple elements (#573)
* fix: resolve unnamed element refs matching multiple elements (#500)

When a page has one unnamed button among several named buttons,
clicking its ref fails with "matched N elements" because the
locator `getByRole('button')` matches all buttons on the page.

Normalize unnamed interactive elements to `name: ""` so the
selector becomes `getByRole('button', { name: "", exact: true })`
which matches only buttons with empty accessible names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove dead code branch in buildSelector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: make RefMap.name required string, remove dead code branches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:42:05 -06:00
Chris Tate 79d8dfe34c add skills to docs (#576) 2026-03-01 09:02:06 -06:00
Chris Tate 14ec5b5ffa add slack skill (#571) 2026-02-28 12:03:50 -06:00
github-actions[bot] 79b05877a8 chore: version packages (#548)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-26 11:45:22 -06:00
Chris Tate 7bd8ce937b chore: add patch changeset for release (#546) 2026-02-26 11:36:47 -06:00
Ryan Siddle b455a58aa2 fix: preserve chrome-extension:// and chrome:// URL schemes in CLI (#410)
The CLI's URL normalization was auto-prepending https:// to any URL
whose scheme wasn't in the allowlist (http, https, about, data, file).
This caused chrome-extension:// URLs to become
https://chrome-extension//... which fails with ERR_NAME_NOT_RESOLVED,
preventing navigation to extension pages (popup, side panel, options).

Add chrome-extension:// and chrome:// to the open command's scheme
allowlist, and update the record start/restart commands to preserve
any URL that already contains :// instead of only checking for http.

Fixes #409
2026-02-26 11:30:02 -06:00
github-actions[bot] b59dc4c82c chore: version packages (#545)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-25 15:57:43 -06:00
Chris Tate 2e38882664 prepare v0.15 (#544)
* add security hardening features

- Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007)
- Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011)
- Add `--allowed-domains` flag to restrict browser navigation to trusted domains
- Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation
- Add `--max-output` flag to truncate large page outputs, preventing context flooding
- New docs page at /security, updated README, SKILL.md, CLI help text, and templates

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* docs

* prepare v0.15
2026-02-25 15:47:26 -06:00
Chris Tate bc1e917e87 add security hardening features (#543)
* add security hardening features

- Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007)
- Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011)
- Add `--allowed-domains` flag to restrict browser navigation to trusted domains
- Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation
- Add `--max-output` flag to truncate large page outputs, preventing context flooding
- New docs page at /security, updated README, SKILL.md, CLI help text, and templates

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* docs
2026-02-25 15:33:20 -06:00
Chris Tate c0e2b80f8c add dogfood skill for agent-driven exploratory qa (#538)
* dogfood skill

* evals

* haiku

* fixes

* caching

* fixes

* don't use npx
2026-02-24 11:35:50 -06:00
Chris Tate f319195974 add --selector flag to scroll command (#537)
* add --selector flag to scroll command

The `scroll` command uses `window.scrollBy()`, which has no effect on apps
that use custom scrollable containers (e.g. a nested div with overflow-y: auto).

The backend `handleScroll` already supports a `selector` parameter, but the CLI
never exposed it. This adds `-s` / `--selector` to the `scroll` command so users
can target a specific scrollable element:

    agent-browser scroll down 500 --selector "div.scroll-container"

Also fixes the backend to apply `direction`/`amount` when a selector is present
(previously those fields were only used in the no-selector branch).

Closes #501

* fixes
2026-02-24 07:40:46 -06:00
Chris Tate 77f2caa1bc feat: add --download-path option (#536)
* feat: add --download-path option

Adds a `--download-path` flag (and `AGENT_BROWSER_DOWNLOAD_PATH` env / `downloadPath` config key) to set a default download directory for browser downloads.

Without this, Playwright stores downloads in a temp directory that is deleted when the browser closes. The new option passes through to Playwright's `downloadsPath` on `launch()` and `launchPersistentContext()`.

Fixes #507

* improvements

* fixes

* fixes
2026-02-24 07:22:55 -06:00
285 changed files with 103498 additions and 24214 deletions
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-356
View File
@@ -1,356 +0,0 @@
---
name: skill-creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
license: Complete terms in LICENSE.txt
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained packages that extend Claude's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform Claude from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share the context window with everything else Claude needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
**Default assumption: Claude is already very smart.** Only add context Claude doesn't already have. Challenge each piece of information: "Does Claude really need this explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match the level of specificity to the task's fragility and variability:
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
Every SKILL.md consists of:
- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Claude reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
- **When to include**: For documentation that Claude should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output Claude produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context
#### What to Not Include in a Skill
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
- README.md
- INSTALLATION_GUIDE.md
- QUICK_REFERENCE.md
- CHANGELOG.md
- etc.
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxilary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by Claude (Unlimited because scripts can be executed without reading into context window)
#### Progressive Disclosure Patterns
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
**Pattern 1: High-level guide with references**
```markdown
# PDF Processing
## Quick start
Extract text with pdfplumber:
[code example]
## Advanced features
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
```
Claude loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
**Pattern 2: Domain-specific organization**
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
```
bigquery-skill/
├── SKILL.md (overview and navigation)
└── reference/
├── finance.md (revenue, billing metrics)
├── sales.md (opportunities, pipeline)
├── product.md (API usage, features)
└── marketing.md (campaigns, attribution)
```
When a user asks about sales metrics, Claude only reads sales.md.
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
```
cloud-deploy/
├── SKILL.md (workflow + provider selection)
└── references/
├── aws.md (AWS deployment patterns)
├── gcp.md (GCP deployment patterns)
└── azure.md (Azure deployment patterns)
```
When the user chooses AWS, Claude only reads aws.md.
**Pattern 3: Conditional details**
Show basic content, link to advanced content:
```markdown
# DOCX Processing
## Creating documents
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
## Editing documents
For simple edits, modify the XML directly.
**For tracked changes**: See [REDLINING.md](REDLINING.md)
**For OOXML details**: See [OOXML.md](OOXML.md)
```
Claude reads REDLINING.md or OOXML.md only when the user needs those features.
**Important guidelines:**
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Claude can see the full scope when previewing.
## Skill Creation Process
Skill creation involves these steps:
1. Understand the skill with concrete examples
2. Plan reusable skill contents (scripts, references, assets)
3. Initialize the skill (run init_skill.py)
4. Edit the skill (implement resources and write SKILL.md)
5. Package the skill (run package_skill.py)
6. Iterate based on real usage
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory>
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
- Adds example files in each directory that can be customized or deleted
After initialization, customize or remove the generated SKILL.md and example files as needed.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Include information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
#### Learn Proven Design Patterns
Consult these helpful guides based on your skill's needs:
- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic
- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns
These files contain established best practices for effective skill design.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
Any example files and directories not needed for the skill should be deleted. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
#### Update SKILL.md
**Writing Guidelines:** Always use imperative/infinitive form.
##### Frontmatter
Write the YAML frontmatter with `name` and `description`:
- `name`: The skill name
- `description`: This is the primary triggering mechanism for your skill, and helps Claude understand when to use the skill.
- Include both what the Skill does and specific triggers/contexts for when to use it.
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Claude.
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
Do not include any other fields in YAML frontmatter.
##### Body
Write instructions for using the skill and its bundled resources.
### Step 5: Packaging a Skill
Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
```bash
scripts/package_skill.py <path/to/skill-folder>
```
Optional output directory specification:
```bash
scripts/package_skill.py <path/to/skill-folder> ./dist
```
The packaging script will:
1. **Validate** the skill automatically, checking:
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
- File organization and resource references
2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
### Step 6: Iterate
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
**Iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again
@@ -1,82 +0,0 @@
# Output Patterns
Use these patterns when skills need to produce consistent, high-quality output.
## Template Pattern
Provide templates for output format. Match the level of strictness to your needs.
**For strict requirements (like API responses or data formats):**
```markdown
## Report structure
ALWAYS use this exact template structure:
# [Analysis Title]
## Executive summary
[One-paragraph overview of key findings]
## Key findings
- Finding 1 with supporting data
- Finding 2 with supporting data
- Finding 3 with supporting data
## Recommendations
1. Specific actionable recommendation
2. Specific actionable recommendation
```
**For flexible guidance (when adaptation is useful):**
```markdown
## Report structure
Here is a sensible default format, but use your best judgment:
# [Analysis Title]
## Executive summary
[Overview]
## Key findings
[Adapt sections based on what you discover]
## Recommendations
[Tailor to the specific context]
Adjust sections as needed for the specific analysis type.
```
## Examples Pattern
For skills where output quality depends on seeing examples, provide input/output pairs:
```markdown
## Commit message format
Generate commit messages following these examples:
**Example 1:**
Input: Added user authentication with JWT tokens
Output:
```
feat(auth): implement JWT-based authentication
Add login endpoint and token validation middleware
```
**Example 2:**
Input: Fixed bug where dates displayed incorrectly in reports
Output:
```
fix(reports): correct date formatting in timezone conversion
Use UTC timestamps consistently across report generation
```
Follow this style: type(scope): brief description, then detailed explanation.
```
Examples help Claude understand the desired style and level of detail more clearly than descriptions alone.
@@ -1,28 +0,0 @@
# Workflow Patterns
## Sequential Workflows
For complex tasks, break operations into clear, sequential steps. It is often helpful to give Claude an overview of the process towards the beginning of SKILL.md:
```markdown
Filling a PDF form involves these steps:
1. Analyze the form (run analyze_form.py)
2. Create field mapping (edit fields.json)
3. Validate mapping (run validate_fields.py)
4. Fill the form (run fill_form.py)
5. Verify output (run verify_output.py)
```
## Conditional Workflows
For tasks with branching logic, guide Claude through decision points:
```markdown
1. Determine the modification type:
**Creating new content?** → Follow "Creation workflow" below
**Editing existing content?** → Follow "Editing workflow" below
2. Creation workflow: [steps]
3. Editing workflow: [steps]
```
@@ -1,303 +0,0 @@
#!/usr/bin/env python3
"""
Skill Initializer - Creates a new skill from template
Usage:
init_skill.py <skill-name> --path <path>
Examples:
init_skill.py my-new-skill --path skills/public
init_skill.py my-api-helper --path skills/private
init_skill.py custom-skill --path /custom/location
"""
import sys
from pathlib import Path
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# {skill_title}
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing"
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text"
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features"
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" → numbered capability list
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Claude produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
"""
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
"""
Example helper script for {skill_name}
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for {skill_name}")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()
'''
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices
"""
EXAMPLE_ASSET = """# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Claude produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.
"""
def title_case_skill_name(skill_name):
"""Convert hyphenated skill name to Title Case for display."""
return ' '.join(word.capitalize() for word in skill_name.split('-'))
def init_skill(skill_name, path):
"""
Initialize a new skill directory with template SKILL.md.
Args:
skill_name: Name of the skill
path: Path where the skill directory should be created
Returns:
Path to created skill directory, or None if error
"""
# Determine skill directory path
skill_dir = Path(path).resolve() / skill_name
# Check if directory already exists
if skill_dir.exists():
print(f"[x] Error: Skill directory already exists: {skill_dir}")
return None
# Create skill directory
try:
skill_dir.mkdir(parents=True, exist_ok=False)
print(f"[OK] Created skill directory: {skill_dir}")
except Exception as e:
print(f"[x] Error creating directory: {e}")
return None
# Create SKILL.md from template
skill_title = title_case_skill_name(skill_name)
skill_content = SKILL_TEMPLATE.format(
skill_name=skill_name,
skill_title=skill_title
)
skill_md_path = skill_dir / 'SKILL.md'
try:
skill_md_path.write_text(skill_content)
print("[OK] Created SKILL.md")
except Exception as e:
print(f"[x] Error creating SKILL.md: {e}")
return None
# Create resource directories with example files
try:
# Create scripts/ directory with example script
scripts_dir = skill_dir / 'scripts'
scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / 'example.py'
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.chmod(0o755)
print("[OK] Created scripts/example.py")
# Create references/ directory with example reference doc
references_dir = skill_dir / 'references'
references_dir.mkdir(exist_ok=True)
example_reference = references_dir / 'api_reference.md'
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
print("[OK] Created references/api_reference.md")
# Create assets/ directory with example asset placeholder
assets_dir = skill_dir / 'assets'
assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / 'example_asset.txt'
example_asset.write_text(EXAMPLE_ASSET)
print("[OK] Created assets/example_asset.txt")
except Exception as e:
print(f"[x] Error creating resource directories: {e}")
return None
# Print next steps
print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description")
print("2. Customize or delete the example files in scripts/, references/, and assets/")
print("3. Run the validator when ready to check the skill structure")
return skill_dir
def main():
if len(sys.argv) < 4 or sys.argv[2] != '--path':
print("Usage: init_skill.py <skill-name> --path <path>")
print("\nSkill name requirements:")
print(" - Hyphen-case identifier (e.g., 'data-analyzer')")
print(" - Lowercase letters, digits, and hyphens only")
print(" - Max 40 characters")
print(" - Must match directory name exactly")
print("\nExamples:")
print(" init_skill.py my-new-skill --path skills/public")
print(" init_skill.py my-api-helper --path skills/private")
print(" init_skill.py custom-skill --path /custom/location")
sys.exit(1)
skill_name = sys.argv[1]
path = sys.argv[3]
print(f"Initializing skill: {skill_name}")
print(f" Location: {path}")
print()
result = init_skill(skill_name, path)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,113 +0,0 @@
#!/usr/bin/env python3
"""
Skill Packager - Creates a distributable .skill file of a skill folder
Usage:
python utils/package_skill.py <path/to/skill-folder> [output-directory]
Example:
python utils/package_skill.py skills/public/my-skill
python utils/package_skill.py skills/public/my-skill ./dist
"""
import sys
import zipfile
from pathlib import Path
# Add script directory to path for sibling imports
sys.path.insert(0, str(Path(__file__).parent))
from quick_validate import validate_skill
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a .skill file.
Args:
skill_path: Path to the skill folder
output_dir: Optional output directory for the .skill file (defaults to current directory)
Returns:
Path to the created .skill file, or None if error
"""
skill_path = Path(skill_path).resolve()
# Validate skill folder exists
if not skill_path.exists():
print(f"[x] Error: Skill folder not found: {skill_path}")
return None
if not skill_path.is_dir():
print(f"[x] Error: Path is not a directory: {skill_path}")
return None
# Validate SKILL.md exists
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
print(f"[x] Error: SKILL.md not found in {skill_path}")
return None
# Run validation before packaging
print("Validating skill...")
valid, message = validate_skill(skill_path)
if not valid:
print(f"[x] Validation failed: {message}")
print(" Please fix the validation errors before packaging.")
return None
print(f"[OK] {message}\n")
# Determine output location
skill_name = skill_path.name
if output_dir:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
skill_filename = output_path / f"{skill_name}.skill"
# Create the .skill file (zip format)
try:
with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the skill directory
for file_path in skill_path.rglob('*'):
if file_path.is_file():
# Calculate the relative path within the zip
arcname = file_path.relative_to(skill_path.parent)
zipf.write(file_path, arcname)
print(f" Added: {arcname}")
print(f"\n[OK] Successfully packaged skill to: {skill_filename}")
return skill_filename
except Exception as e:
print(f"[x] Error creating .skill file: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
print("\nExample:")
print(" python utils/package_skill.py skills/public/my-skill")
print(" python utils/package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"Packaging skill: {skill_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_skill(skill_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import sys
import os
import re
import yaml
from pathlib import Path
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
# Check SKILL.md exists
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
return False, "SKILL.md not found"
# Read and validate frontmatter
content = skill_md.read_text()
if not content.startswith('---'):
return False, "No YAML frontmatter found"
# Extract frontmatter
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter_text = match.group(1)
# Parse YAML frontmatter
try:
frontmatter = yaml.safe_load(frontmatter_text)
if not isinstance(frontmatter, dict):
return False, "Frontmatter must be a YAML dictionary"
except yaml.YAMLError as e:
return False, f"Invalid YAML in frontmatter: {e}"
# Define allowed properties
ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata'}
# Check for unexpected properties (excluding nested keys under metadata)
unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES
if unexpected_keys:
return False, (
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. "
f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}"
)
# Check required fields
if 'name' not in frontmatter:
return False, "Missing 'name' in frontmatter"
if 'description' not in frontmatter:
return False, "Missing 'description' in frontmatter"
# Extract name for validation
name = frontmatter.get('name', '')
if not isinstance(name, str):
return False, f"Name must be a string, got {type(name).__name__}"
name = name.strip()
if name:
# Check naming convention (hyphen-case: lowercase with hyphens)
if not re.match(r'^[a-z0-9-]+$', name):
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
if name.startswith('-') or name.endswith('-') or '--' in name:
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
# Check name length (max 64 characters per spec)
if len(name) > 64:
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
# Extract and validate description
description = frontmatter.get('description', '')
if not isinstance(description, str):
return False, f"Description must be a string, got {type(description).__name__}"
description = description.strip()
if description:
# Check for angle brackets
if '<' in description or '>' in description:
return False, "Description cannot contain angle brackets (< or >)"
# Check description length (max 1024 characters per spec)
if len(description) > 1024:
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)
-23
View File
@@ -1,23 +0,0 @@
# Changesets
This project uses [Changesets](https://github.com/changesets/changesets) for versioning and changelog generation.
## Adding a changeset
When you make a change that should be released, run:
```bash
pnpm changeset
```
This will prompt you to:
1. Select the type of change (patch, minor, major)
2. Write a summary of your changes
The changeset file will be committed with your PR.
## Release process
When changesets are merged to `main`, the release workflow will:
1. Create a "Version Packages" PR that updates version numbers and changelogs
2. When that PR is merged, packages are automatically published to npm
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "agent-browser",
"description": "Headless browser automation for AI agents",
"description": "Browser automation for AI agents",
"owner": {
"name": "Vercel",
"email": "support@vercel.com"
+70 -137
View File
@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
version-sync:
@@ -17,51 +18,39 @@ jobs:
- name: Check version sync
run: node scripts/check-version-sync.js
typescript:
name: TypeScript (Node ${{ matrix.node-version }})
rust:
name: Rust
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
version: 9
components: rustfmt, clippy
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Typecheck
run: pnpm typecheck
workspaces: cli
- name: Format check
run: pnpm format:check
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Clippy check
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
- name: Run tests
run: pnpm test
- name: Run Rust tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml
rust:
rust-cross:
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
if: github.event_name != 'pull_request'
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-latest
@@ -78,72 +67,61 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Cache Cargo dependencies
uses: actions/cache@v4
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
cli/target/
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.target }}-
- name: Build release binary
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
workspaces: cli
- name: Run Rust tests
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
windows-integration:
name: Windows Integration Test
runs-on: windows-latest
native-e2e:
name: Native E2E Tests
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: rust
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
node-version: 22
cache: pnpm
workspaces: cli
- name: Install Chrome
run: |
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
- name: Run e2e tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
windows-integration:
name: Windows Integration Test
if: github.event_name != 'pull_request'
runs-on: windows-latest
needs: rust-cross
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Cargo dependencies
uses: actions/cache@v4
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
cli/target/
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
restore-keys: |
windows-cargo-x86_64-pc-windows-msvc-
workspaces: cli
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory
run: |
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
@@ -161,53 +139,28 @@ jobs:
shell: pwsh
timeout-minutes: 10
- name: Verify Chromium was installed
- name: Test daemon lifecycle (open, snapshot, close)
run: |
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
if (Test-Path $playwrightPath) {
Write-Host "Playwright browsers installed at: $playwrightPath"
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
} else {
Write-Error "Playwright browsers not found!"
exit 1
}
$env:PATH = "$pwd\bin;$env:PATH"
Write-Host "--- Opening page ---"
bin/agent-browser-win32-x64.exe open https://example.com
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
Write-Host "--- Taking snapshot ---"
$snapshot = bin/agent-browser-win32-x64.exe snapshot
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
Write-Host $snapshot
Write-Host "--- Closing browser ---"
bin/agent-browser-win32-x64.exe close
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
Write-Host "--- Windows daemon lifecycle test passed ---"
shell: pwsh
serverless-chromium:
name: Serverless Chromium (@sparticuz/chromium)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Install @sparticuz/chromium
run: pnpm add -D @sparticuz/chromium
- name: Build TypeScript
run: pnpm build
- name: Run serverless integration test
run: pnpm exec vitest run test/serverless.test.ts
timeout-minutes: 5
global-install:
name: Global Install (${{ matrix.os }})
if: github.event_name != 'pull_request'
runs-on: ${{ matrix.os }}
needs: rust
needs: rust-cross
strategy:
matrix:
include:
@@ -225,44 +178,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Cargo dependencies
uses: actions/cache@v4
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
cli/target/
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.target }}-
workspaces: cli
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory (Unix)
if: runner.os != 'Windows'
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
@@ -289,7 +222,7 @@ jobs:
echo "ERROR: Symlink should point to native binary, not JS wrapper"
exit 1
fi
echo "✓ Symlink correctly points to native binary"
echo "Symlink correctly points to native binary"
shell: bash
- name: Verify shim points to native binary (Windows)
@@ -304,5 +237,5 @@ jobs:
echo "ERROR: Shim should point to native .exe, not JS wrapper"
exit 1
}
echo "✓ Shim correctly points to native binary"
echo "Shim correctly points to native binary"
shell: pwsh
+97 -51
View File
@@ -10,13 +10,40 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
# Build native binaries for all platforms first
check-release:
name: Check for new version
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check.outputs.should_release }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Compare package.json version to npm
id: check
run: |
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")
echo "npm version: $NPM_VERSION"
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
echo "should_release=true" >> "$GITHUB_OUTPUT"
else
echo "Version unchanged, skipping release"
echo "should_release=false" >> "$GITHUB_OUTPUT"
fi
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
build-binaries:
name: Build ${{ matrix.name }}
needs: check-release
if: needs.check-release.outputs.should_release == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -32,6 +59,16 @@ jobs:
target: aarch64-unknown-linux-gnu
binary: agent-browser-linux-arm64
use_zigbuild: true
- name: Linux musl x64
os: ubuntu-latest
target: x86_64-unknown-linux-musl
binary: agent-browser-linux-musl-x64
use_zigbuild: true
- name: Linux musl ARM64
os: ubuntu-latest
target: aarch64-unknown-linux-musl
binary: agent-browser-linux-musl-arm64
use_zigbuild: true
- name: Windows x64
os: ubuntu-latest
target: x86_64-pc-windows-gnu
@@ -98,18 +135,10 @@ jobs:
linker = "x86_64-w64-mingw32-gcc"
EOF
- name: Cache Cargo dependencies
uses: actions/cache@v4
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
cli/target/
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.target }}-
workspaces: cli
- name: Build with zigbuild
if: matrix.use_zigbuild
@@ -136,19 +165,13 @@ jobs:
path: artifacts/${{ matrix.binary }}
retention-days: 7
# Create release PR or publish to npm (with binaries)
release:
name: Release
needs: build-binaries
publish:
name: Publish to npm
needs: [check-release, build-binaries]
runs-on: ubuntu-latest
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
steps:
- name: Checkout Repo
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -162,7 +185,7 @@ jobs:
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download all binary artifacts
@@ -184,11 +207,13 @@ jobs:
EXPECTED_BINARIES=(
"agent-browser-linux-x64"
"agent-browser-linux-arm64"
"agent-browser-linux-musl-x64"
"agent-browser-linux-musl-arm64"
"agent-browser-win32-x64.exe"
"agent-browser-darwin-x64"
"agent-browser-darwin-arm64"
)
MIN_SIZE=100000 # Binaries should be at least 100KB
MIN_SIZE=100000
ERRORS=0
for binary in "${EXPECTED_BINARIES[@]}"; do
if [ ! -f "bin/$binary" ]; then
@@ -208,30 +233,20 @@ jobs:
echo "Error: $ERRORS binary issues found"
exit 1
fi
echo "All 5 platform binaries present and valid"
echo "All 7 platform binaries present and valid"
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: 'chore: version packages'
commit: 'chore: version packages'
- name: Publish to npm
run: pnpm publish --no-git-checks
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
# Create GitHub release with binaries after npm publish
github-release:
name: Create GitHub Release
needs: release
if: needs.release.outputs.published == 'true'
needs: [check-release, publish]
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
- name: Download all artifacts
uses: actions/download-artifact@v4
@@ -249,28 +264,59 @@ jobs:
- name: Verify binaries exist
run: |
BINARY_COUNT=$(ls bin/agent-browser-* 2>/dev/null | wc -l)
if [ "$BINARY_COUNT" -lt 5 ]; then
echo "Error: Expected 5 binaries, found $BINARY_COUNT"
if [ "$BINARY_COUNT" -lt 7 ]; then
echo "Error: Expected 7 binaries, found $BINARY_COUNT"
ls -la bin/
exit 1
fi
echo "Found $BINARY_COUNT binaries"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: pnpm install --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 }}"
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
if [ "$LINES" -lt 2 ]; then
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
exit 1
fi
echo "Extracted release notes for $VERSION ($LINES lines)"
- name: Create GitHub Release
run: |
VERSION=$(node -p "require('./package.json').version")
VERSION="${{ needs.check-release.outputs.version }}"
TAG="v$VERSION"
# Check if release already exists
if gh release view "$TAG" &>/dev/null; then
echo "Release $TAG already exists, uploading binaries..."
gh release upload "$TAG" bin/agent-browser-* --clobber
echo "Release $TAG already exists, uploading assets..."
gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber
else
echo "Creating release $TAG..."
gh release create "$TAG" \
--title "$TAG" \
--generate-notes \
bin/agent-browser-*
--notes-file /tmp/release-notes.md \
bin/agent-browser-* dashboard.zip
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+13
View File
@@ -6,6 +6,7 @@ dist/
# Native binaries (keep the launcher scripts)
bin/agent-browser-*
bin/.install-method
!bin/agent-browser
!bin/agent-browser.cmd
@@ -27,10 +28,15 @@ npm-debug.log*
.DS_Store
Thumbs.db
# Python
__pycache__/
# Test artifacts
*.png
*.jpeg
*.jpg
*.webm
test/e2e/.dogfood-output/
# Package manager
package-lock.json
@@ -40,6 +46,9 @@ yarn.lock
.env
.env.local
# Windows debug instance config
scripts/windows-debug/.instance
# opensrc - source code for packages
opensrc/
@@ -51,3 +60,7 @@ docs/package-lock.json
# pnpm
.pnpm-store/
# next
.next/
out/
-1
View File
@@ -1,3 +1,2 @@
pnpm lint-staged
node scripts/sync-version.js
git add cli/Cargo.toml cli/Cargo.lock
+163 -4
View File
@@ -9,6 +9,7 @@ This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for ins
## Code Style
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
- In documentation and markdown, never use double hyphens (`--`) as a dash. Use an emdash (—) sparingly when needed. Prefer rewriting the sentence to avoid dashes entirely.
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
- CLI flags must always use kebab-case (e.g., `--auto-connect`, `--allow-file-access`). Never use camelCase for flags (e.g., `--autoConnect` is wrong).
@@ -16,16 +17,174 @@ This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for ins
When adding or changing user-facing features (new flags, commands, behaviors, environment variables, etc.), update **all** of the following:
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)
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
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 `<table>` 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.
To prepare a release:
1. Create a branch (e.g. `prepare-v0.24.0`)
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 `## <version>` heading, wrapped in `<!-- release:start -->` and `<!-- release:end -->` 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`
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 `<!-- release:start -->` and `<!-- release:end -->` markers in `CHANGELOG.md`.
### Writing the changelog
Review the git log since the last release and write the entry in `CHANGELOG.md`. Follow the existing format and voice. Group changes under `### New Features`, `### Bug Fixes`, `### Improvements`, etc. Bold the feature/fix name, then describe it concisely. Reference PR numbers in parentheses.
Wrap the release notes (everything between the `## <version>` heading and the previous version) in markers so CI can extract them for the GitHub release:
```markdown
## 0.24.0
<!-- release:start -->
### New Features
- **Foo command** - Added `foo` command for bar (#1234)
### Bug Fixes
- Fixed **baz** not working when qux is enabled (#1235)
### Contributors
- @ctate
- @somecontributor
<!-- release:end -->
## 0.23.3
```
Include a `### Contributors` section listing the GitHub usernames (with `@` prefix) of everyone who contributed to the release. Check the git log between the previous tag and HEAD to find them.
Do not prefix entries with commit hashes. Do not use the changesets `### Patch Changes` / `### Minor Changes` headings. Use descriptive section names instead.
### Docs changelog
The docs changelog at `docs/src/app/changelog/page.mdx` mirrors `CHANGELOG.md` but uses a slightly different format. Each entry uses:
- A `v` prefix on the version (e.g. `## v0.24.0`)
- A date line with the full date: `<p className="text-[#888] text-sm">March 30, 2026</p>`
- A `---` separator between entries
Match the existing style in that file.
## Architecture
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
## Testing
### Unit Tests
```bash
cd cli && cargo test
```
Runs all unit tests (~320 tests). These are fast and don't require Chrome.
### End-to-End Tests
```bash
cd cli && cargo test e2e -- --ignored --test-threads=1
```
Runs 18 e2e tests that launch real headless Chrome instances and exercise the full native daemon command pipeline. Requirements:
- Chrome must be installed
- Must run serially (`--test-threads=1`) to avoid Chrome instance contention
- Tests are `#[ignore]`'d so they don't run during normal `cargo test`
The e2e tests live in `cli/src/native/e2e_tests.rs` and cover: launch/close, navigation, snapshots, screenshots, form interaction, cookies, storage, tabs, element queries, viewport/emulation, domain filtering, diff, state management, error handling, and Phase 8 commands.
### Linting and Formatting
```bash
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 "<powershell-command>"
```
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.
<!-- opensrc:start -->
## Source Code Reference
+534 -6
View File
@@ -1,14 +1,539 @@
# agent-browser
## 0.14.0-fork.3
## 0.24.0
<!-- release:start -->
### New Features
- **AWS Bedrock AgentCore provider** - Added AWS Bedrock AgentCore as a cloud browser provider. Connect with `--provider agentcore` or `AGENT_BROWSER_PROVIDER=agentcore`. Uses lightweight manual SigV4 signing for authentication with support for the full AWS credential provider chain (environment variables, AWS CLI, SSO, IAM roles). Configure with `AGENTCORE_REGION`, `AGENTCORE_PROFILE_ID`, and `AGENTCORE_BROWSER_ID` environment variables. Returns session ID and Live View URL in the launch response (#397)
### Documentation
- Added AgentCore provider page to docs site, README options table, SKILL.md, and dashboard provider icons (#1120)
### Contributors
- @ctate
- @pahud
<!-- release:end -->
## 0.23.4
### Bug Fixes
- Fixed **daemon hang on Linux** caused by a `waitpid(-1)` race condition in the SIGCHLD handler that stole exit statuses from Rust's `Child` handles, leaving the daemon in a broken state. Replaced the global signal handler with targeted crash detection via the existing drain interval (#1098)
## 0.23.3
### Bug Fixes
- Fixed **drag and drop** not working because `mouseMoved` events during the drag omitted the `buttons` bitmask, causing the browser to see `event.buttons === 0` and never fire `dragstart`/`dragover`/`drop` (#1087)
## 0.23.2
### Patch Changes
- Fix CLI typing delay parsing so `--delay` is treated as an option instead of typed text.
- Add `--delay <ms>` parsing for `type` and `keyboard type`
- Support `--` to type literal `--delay` text
- Add regression tests for parsing and delay behavior
- Update CLI help, README, skills, and docs command references
- 3c942e2: ### New Features
- **Dashboard session creation** - Sessions can now be created directly from the dashboard UI. A new session dialog provides a unified selector grid for local engines (Chrome, Lightpanda) and cloud providers (Browserbase, Browserless, Browser Use, Kernel) with async creation, loading state, and error display (#1092)
- **Dashboard provider icons** - The session sidebar now shows the provider or engine icon for each session, making it easy to identify which backend a session is using (#1092)
### Bug Fixes
- Fixed **Browser Use** provider using an intermediate API call instead of connecting directly via WSS (`wss://connect.browser-use.com`), which caused connection failures (#1092)
- Fixed **Browserbase** provider not sending an explicit JSON body and `Content-Type` header, causing session creation to fail (#1092)
- Fixed **provider navigation** hanging because `wait_for_lifecycle` waited for page load events that remote providers may not emit. Navigation with `--provider` now automatically sets `waitUntil=none` (#1092)
- Fixed **remote CDP connections** timing out by increasing the CDP connect timeout from 10s to 25s for cloud providers (#1092)
- Fixed **zombie daemon processes** not being cleaned up when a provider connection fails during session creation from the dashboard (#1092)
## 0.23.1
### Patch Changes
- fbcab37: ### New Features
- **Auto-dismissal for alert and beforeunload dialogs** - JavaScript `alert()` and `beforeunload` dialogs are now automatically accepted to prevent the agent from blocking indefinitely. `confirm` and `prompt` dialogs still require explicit `dialog accept/dismiss` commands. Disable with `--no-auto-dialog` flag or `AGENT_BROWSER_NO_AUTO_DIALOG` environment variable (#1075)
- **Puppeteer browser cache fallback** - Chrome discovery now searches `~/.cache/puppeteer/chrome/` (or `PUPPETEER_CACHE_DIR`) for Chrome binaries, so users with an existing Puppeteer installation can use agent-browser without a separate install step (#1088)
- **Console output improvements** - `console.log` of objects now shows the actual object preview (e.g. `{userId: "abc", count: 42}`) instead of `"Object"`. JSON output includes a raw `args` array for programmatic access (#1040)
### Bug Fixes
- Fixed **same-document navigation** (e.g. SPA hash routing) hanging forever because `wait_for_lifecycle` waited for a `Page.loadEventFired` that never fires on same-document navigations (#1059)
- Fixed **save_state** only capturing cookies and localStorage for the current origin, silently dropping cross-domain data (e.g. SSO/CAS auth cookies). Now uses `Network.getAllCookies` and collects localStorage from all visited origins (#1064)
- Fixed **externally opened tabs** not appearing in `tab list` when using `--cdp` mode. Tabs opened by the user or another CDP client are now detected and tracked (#1042)
- Fixed **dashboard server** not picking up installed files without a restart. `dashboard install` now takes effect immediately on a running server (#1066)
- Fixed **Windows Chrome extraction** failing because zip path normalization used forward slashes while the extraction code expected backslashes (#1088)
## 0.23.0
### Minor Changes
- 0f0f300: ### New Features
- **Observability dashboard** - Added a local web UI (`dashboard`) that shows live browser viewports, command activity feeds, console output, network requests, storage, and extensions for all sessions. Manage it with `dashboard start`, `dashboard stop`, and `dashboard install`. The dashboard runs as a standalone background process and all sessions stream to it automatically (#1034)
- **Runtime stream management** - Added `stream enable`, `stream disable`, and `stream status` commands to control WebSocket streaming at runtime. Streaming is now always enabled by default; `AGENT_BROWSER_STREAM_PORT` overrides the port instead of toggling the feature (#951)
- **Close all sessions** - Added `close --all` flag to close every active browser session at once
### Bug Fixes
- Fixed **Lightpanda engine** compatibility (#1050)
- Fixed **Windows daemon TCP bind** failing when Hyper-V reserves the port by falling back to an OS-assigned port and writing it to a `.port` file (#1041)
- Fixed **Windows dashboard relay** using Unix socket instead of TCP (#1038)
- Fixed **radio/checkbox elements** being dropped from compact snapshot tree because the `ref=` check required a leading `[` that those elements lack (#1008)
## 0.22.3
### Patch Changes
- eb64ca4: ### Bug Fixes
- **Re-apply download behavior on recording context** - Fixed an issue where downloads were silently dropped in recording contexts because `Browser.setDownloadBehavior` set at launch only applied to the default context. The download behavior is now re-applied when a new recording context is created (#1019)
- **Reap zombie Chrome process and fast-detect crash for auto-restart** - Added a non-blocking process-exit check before attempting CDP connection checks. This prevents a 3-second CDP timeout when Chrome has already crashed or exited, enabling faster detection and auto-restart of the browser (#1023)
- **Route keyboard `type` through text input** - Fixed keyboard `type` subaction to correctly route through the text input handler, and added support for an `insertText` subaction using `Input.insertText` (#1014)
- **Handle `--clear` flag in `console` command** - Fixed the `console` command to accept and process a `clear` parameter, allowing console event history to be cleared (#1015)
## 0.22.2
### Patch Changes
- a098197: ### New Features
- **Dialog status command** - Added `dialog status` command to check whether a JavaScript dialog is currently open (#999)
- **Dialog warning field** - Command responses now include a `warning` field when a JavaScript dialog is pending, indicating the dialog type and message (#999)
### Improvements
- **Standard proxy environment variables** - The proxy setting now automatically falls back to standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and their lowercase variants), with `NO_PROXY`/`no_proxy` respected for bypass rules (#1000)
- **Font packages for `--with-deps`** - Installing with `--with-deps` now includes CJK and emoji font packages on Linux (Debian, RPM, and yum-based distros) to prevent missing glyphs when rendering international content (#1002)
### Bug Fixes
- Fixed `state show` always failing with "Missing 'path' parameter" due to a mismatched JSON field name (`filename` → `path`) (#994)
- Fixed `console` command returning only `Done` due to a JSON field name mismatch in the response (#986)
- Fixed browser-domain CDP events being dropped during downloads due to a `sessionId` mismatch (#998)
- Fixed proxy authentication by handling credentials via the CDP `Fetch.authRequired` event rather than passing them inline (#1000)
## 0.22.1
### Patch Changes
- 3a3317b: ### Bug Fixes
- Fixed **modifier key chords** (e.g. `Control+a`, `Shift+Enter`, `Control+Shift+a`) not being handled correctly when using `press`. Modifier keys (`Alt`, `Control`/`Ctrl`, `Meta`/`Cmd`, `Shift`) are now parsed and forwarded as CDP modifier bitmasks rather than treated as part of the key name (#980)
- Fixed **query parameters being dropped** from `--cdp` HTTP URLs (e.g. `http://host:9222?mode=Hello`). Query strings are now preserved and forwarded to the remote CDP endpoint (#982)
## 0.22.0
### Minor Changes
- be30bc9: ### New Features
- **Cross-origin iframe support** - Added support for snapshots and interactions within cross-origin iframes via `Target.setAutoAttach` (#949)
- **Network request detail and filtering** - Added `network request <requestId>` command to view full request/response detail, and new filtering options for `network requests` including `--type` (e.g. `xhr,fetch`), `--method` (e.g. `POST`), and `--status` (e.g. `2xx`, `400-499`) (#935)
### Improvements
- **Snapshot usability** - Reduced AI cognitive load by filtering semantic noise from snapshot output; cursor-interactive elements are now included by default, making the `-C` flag unnecessary (#968)
- **Upgrade command** - Improved robustness of installation method detection in the upgrade command (#960)
- **Target tracking** - Enhanced target tracking and page information handling for more reliable browser session management (#969)
### Bug Fixes
- Fixed **viewport dimensions** being reported incorrectly in streaming status messages and screencast (#952)
- Fixed **`find` command** flags such as `--exact` and `--name` leaking into fill values when used with fill actions (#955)
- Fixed **state commands** incorrectly starting the daemon when no `session_name` is provided (#677, #964)
- Fixed **auto-connect** triggering when the daemon is already running, preventing duplicate connections (#971)
- Fixed **Enter key press** not working by adding a text field to `keyDown` events (#972)
- Fixed **download command** to properly handle absolute paths and correctly click target elements (#970)
### Breaking Changes
- The `-C` / `--cursor` flag for `snapshot` is deprecated; cursor-interactive elements are now included by default and the flag has no additional effect (#968)
### Documentation
- Updated `README.md` with new `network requests` filtering options and `network request <requestId>` command usage
- Removed references to the deprecated `-C` / `--cursor` snapshot flag from docs and command reference
## 0.21.4
### Patch Changes
- aed466b: ### Bug Fixes
- **Auth login readiness** - `agent-browser auth login` now navigates with `load`, waits for usable login form selectors, and uses staged username detection (targeted email/username selectors first, then broad text-input fallback). This reduces SPA timing failures, avoids false matches on unrelated text fields, and prevents `networkidle` hangs on pages with continuous background requests.
## 0.21.3
### Patch Changes
- 6daad22: ### Bug Fixes
- **WebSocket keepalive for remote browsers** - Added WebSocket Ping frames and TCP `SO_KEEPALIVE` to prevent CDP connections from being silently dropped by intermediate proxies (reverse proxies, load balancers, service meshes) during idle periods (#936)
- **XPath selector support** - Fixed element resolution to correctly handle the `xpath=` selector prefix (#908)
### Performance
- **Fast-path for identical snapshots** - Short-circuits the Myers diff algorithm when comparing a snapshot to itself, avoiding unnecessary computation in retry and loop workloads where repeated identical snapshots are common (#922)
### Documentation
- Migrated page metadata from MDX files to `layout.tsx` (#904)
- Added search functionality and color improvements to docs (#927)
- Fixed desktop browser list in the iOS comparison table (#926)
- Created a new `providers/` section with dedicated provider pages (#928)
## 0.21.2
### Patch Changes
- 757626f: ### Bug Fixes
- **Deduplicate text content in snapshots** - Fixed an issue where duplicate text content appeared in page snapshots (#909)
- **Native mouse drag state** - Fixed incorrect raw native mouse drag state not being properly tracked across `down`, `move`, and `up` events (#872)
- **Chrome headless launch failures** - Fixed browser launch failures caused by the `--enable-unsafe-swiftshader` flag in Chrome headless mode (#915)
- **Origin-scoped `--headers` persistence** - Restored correct persistence of origin-scoped headers set via `--headers` across navigation commands (#894)
- **Relative URLs in WebSocket domain filter** - Fixed handling of relative URLs in the WebSocket domain filter script (#624)
## 0.21.1
### Patch Changes
- 1e7619d: ### New Features
- **HAR 1.2 network capture** - Added commands to capture and export network traffic in HAR 1.2 format, including accurate request/response timing, headers, body sizes, and resource types sourced from Chrome DevTools Protocol events (#864)
- **Built-in `upgrade` command** - Added `agent-browser upgrade` to self-update the CLI; automatically detects your installation method (npm, Homebrew, or Cargo) and runs the appropriate update command (#898)
### Documentation
- Added `upgrade` command to the README command reference and installation guide
- Added a dedicated **Updating** section to the README with usage instructions for `agent-browser upgrade`
## 0.21.0
### Minor Changes
- c6de80b: ### New Features
- **`batch` command** -- Execute multiple commands from stdin in a single invocation. Accepts a JSON array of string arrays and returns results sequentially. Supports `--bail` to stop on first error and `--json` for structured output (#865)
- **iframe support** -- CLI interactions and snapshots now traverse into iframe content, enabling automation of cross-frame pages (#869)
- **`network har start/stop` command** -- Capture and export network traffic in HAR 1.2 format (#874)
- **WebSocket fallback for CDP discovery** -- When HTTP-based CDP endpoint discovery fails, the CLI now falls back to a WebSocket connection automatically (#873)
### Improvements
- **`--full`/`-f` refactored to command-level flag** -- Moved from a global flag to a per-command flag for clearer scoping (#877)
- **Enhanced Chrome launch** -- Added `--user-data-dir` support and configurable launch timeout for more reliable browser startup (#852)
### Bug Fixes
- Fixed `/json/list` fallback when `/json/version` endpoint is unavailable, improving compatibility with non-standard CDP implementations (#861)
- Fixed daemon liveness detection for PID namespace isolation (e.g. `unshare`). Uses socket connectivity as the sole liveness check instead of `kill(pid, 0)`, which fails when the caller cannot see the daemon's PID (#879)
- Fixed Ubuntu dependency install accidentally removing system packages (#884)
## 0.20.14
### Patch Changes
- c0d4cf6: ### New Features
- **Idle timeout for daemon auto-shutdown** - Added `--idle-timeout` CLI flag (and `AGENT_BROWSER_IDLE_TIMEOUT_MS` environment variable) to automatically shut down the daemon after a period of inactivity. Accepts human-friendly formats such as `10s`, `3m`, `1h`, or raw milliseconds (#856)
- **Cursor-interactive elements in snapshot tree** - Cursor-interactive elements are now embedded directly into the snapshot tree for richer context (#855)
### Bug Fixes
- Fixed **remote host support** in CDP discovery, enabling connection to browsers running on non-local hosts (#854)
- Fixed **CDP flag propagation** to the daemon process, ensuring reliable CDP reconnection across sessions (#857)
- Fixed **Windows auto-connect profiling** to correctly handle browser connection on Windows (#835, #840)
- Fixed **Windows transient error detection** by recognising Windows-specific socket error codes (`os error 10061` connection refused, `os error 10054` connection reset) during daemon reconnection attempts
## 0.20.13
### Patch Changes
- eda956b: ### Bug Fixes
- **Network idle detection for cached pages** - Fixed an issue where `poll_network_idle` could return immediately when no network events were observed (e.g. pages served from cache). The idle timer is now only satisfied after a consistent **500 ms idle period** has elapsed, preventing false-positive idle detection. The core polling logic has also been extracted into a standalone `poll_network_idle` function to improve testability (#847)
## 0.20.12
### Patch Changes
- 5fa2396: ### Bug Fixes
- Fixed **`snapshot -C`** and **`screenshot --annotate`** hanging when connected over WSS (WebSocket Secure) due to sequential CDP round-trips per interactive element (#842)
### Performance
- **`snapshot -C` (cursor-interactive mode)** now batches CDP calls instead of issuing N×2 sequential round-trips per cursor-interactive element, preventing timeouts on high-latency WSS connections (#842)
- **`screenshot --annotate`** now batches element queries, reducing completion time from potentially 20–40s (e.g. 50+ buttons over WSS) to within expected bounds (#842)
## 0.20.11
### Patch Changes
- 4b5fc78: ### Bug Fixes
- **Material Design checkbox/radio parity** - Restored Playwright-parity behavior for `check`/`uncheck` actions on Material Design controls. These components hide the native `<input>` off-screen and use overlay elements that intercept coordinate-based clicks; the actions now detect this pattern and fall back to a JS `.click()` to correctly toggle state. Also improves `ischecked` to handle nested hidden inputs and ARIA-only checkboxes (#837)
- **Punctuation handling in `type` command** - Fixed incorrect virtual key (VK) codes being used for punctuation characters (e.g. `.`, `@`) in the `type` action, which previously caused those characters to be dropped or mistyped (#836)
## 0.20.10
### Patch Changes
- a3d9662: ### Bug Fixes
- **Restored WebSocket streaming** - Fixed broken WebSocket streaming in the native daemon by keeping the **StreamServer** instance alive so the broadcast channel remains open, and ensuring CDP session IDs and connection status are correctly propagated to stream clients (#826)
- **Filtered internal Chrome targets** - Fixed auto-connect discovery incorrectly attempting to attach to Chrome-internal pages (e.g. `chrome://`, `chrome-extension://`, `devtools://` URLs), which could cause unexpected connection failures (#827)
## 0.20.9
### Patch Changes
- 51d9ab4: ### Bug Fixes
- **Appium v3 iOS capabilities** - Added `appium:` vendor prefix to iOS capabilities (e.g., `appium:automationName`, `appium:deviceName`, `appium:platformVersion`) to comply with the Appium v3 WebDriver protocol requirements (#810)
- **Snapshot `--selector` scoping** - Fixed `snapshot --selector` so that the output is properly scoped to the matched element's subtree rather than returning the full accessibility tree. The selector now resolves the target DOM node's backend IDs and filters the accessibility tree to only include nodes within that subtree (#825)
## 0.20.8
### Patch Changes
- daf7263: ### Bug Fixes
- Fixed **video duration** being reported incorrectly when using real-time ffmpeg encoding for screen recording (#812)
- Removed obsolete **`BrowserManager` TypeScript API** references that no longer reflect the current CLI-based usage model (#821)
### Documentation
- Updated README to replace outdated **`BrowserManager` programmatic API** examples with the current CLI-based approach using `execSync` and `agent-browser` commands (#821)
- Removed the **Programmatic API** section covering `BrowserManager` screencast and input injection methods, which are no longer part of the public API (#821)
## 0.20.7
### Patch Changes
- 25a1526: ### New Features
- **Brave Browser support** - Added auto-discovery of Brave Browser for CDP connections on macOS, Linux, and Windows. The agent will now automatically detect and connect to Brave alongside Chrome, Chromium, and Canary installations (#817)
### Improvements
- **Postinstall message** - The post-install message now detects existing Chrome installations on the system. If a compatible browser is found, it confirms the path and notes it will be used automatically instead of prompting an install. If no browser is detected, the warning is clearer and mentions that installation can be skipped when using `--cdp`, `--provider`, `--engine`, or `--executable-path` (#815)
## 0.20.6
### Patch Changes
- fa91c22: ### Bug Fixes
- **Stale accessibility tree reference fallback** - Fixed an issue where interacting with an element whose **`backend_node_id`** had become stale (e.g. after the DOM was replaced) would fail with a `Could not compute box model` CDP error. Element resolution now re-queries the accessibility tree using role/name lookup to obtain a fresh node ID before retrying the operation (#806)
## 0.20.5
### Patch Changes
- fc091d2: ### Bug Fixes
- **Daemon panic on broken stderr pipe** - Replaced all `eprintln!` calls with `writeln!(std::io::stderr(), ...)` wrapped in `let _ =` to silently discard write errors, preventing the daemon from panicking when the parent process drops the stderr pipe during Chrome launch (#802)
## 0.20.4
### Patch Changes
- e2ebde2: ### Bug Fixes
- **Broadcast channel lag handling** - Fixed an issue where **broadcast channel lag** errors were incorrectly treated as stream closure, causing premature termination of event listeners in reload, response body, download, and navigation wait operations. Lagged messages are now skipped and the loop continues instead of breaking (#797)
### Improvements
- Removed unused **pnpm setup** steps from the `global-install` CI job, simplifying the workflow configuration (#798)
## 0.20.3
### Patch Changes
- e365909: ### Bug Fixes
- **Chrome launch retry** - Chrome will now retry launching up to 3 times with a 500ms delay between attempts, improving resilience against transient startup failures (#791)
- **Remote CDP snapshot hang** - Resolved an issue where snapshots would hang indefinitely over remote CDP (WSS) connections by removing WebSocket message and frame size limits to accommodate large responses (e.g. `Accessibility.getFullAXTree`), accepting binary frames from remote proxies such as Browserless, and immediately clearing pending commands when the connection closes rather than waiting for the 30-second timeout (#792)
## 0.20.2
### Patch Changes
- 944fa01: ### New Features
- **Linux musl (Alpine) builds** - Added pre-built binaries for **linux-musl** targeting both **x64** and **arm64** architectures, enabling native support for Alpine Linux and other musl-based distributions without requiring glibc (#784)
### Improvements
- **Consecutive `--auto-connect` commands** - Added support for issuing multiple consecutive `--auto-connect` commands without requiring a full browser relaunch; external connections are now correctly identified and reused (#786)
- **External browser disconnect behavior** - When using `--auto-connect` or `--cdp`, closing the agent session now disconnects cleanly without shutting down the user's browser process
### Bug Fixes
- **Restored `refs` dict in `--json` snapshot output** - The `refs` map containing role and name metadata for referenced elements is now correctly included in JSON snapshot responses (#787)
- Fixed e2e test assertions for `diff_snapshot` and `domain_filter` to correctly reflect expected behavior (#783)
- Fixed Chrome temp-dir cleanup test failing on Windows (#766)
## 0.20.1
### Patch Changes
- bd05917: ### Bug Fixes
- Fixed **AX tree deserialization** to accept integer `nodeId` and `childIds` values for compatibility with Lightpanda, which sends numeric IDs where Chrome sends strings (#775)
- Fixed **misleading SIGPIPE comment** to accurately describe the default Rust SIGPIPE behavior and why it is reset to `SIG_DFL` (#776)
- Fixed **WebM recording output** to use the VP9 codec (`libvpx-vp9`) instead of H.264, producing valid WebM files; also adds a padding filter to ensure even frame dimensions (#779)
## 0.20.0
### Minor Changes
- 235fa88: ### Full Native Rust
- **100% native Rust** -- Removed the entire Node.js/Playwright daemon. The Rust native daemon is now the only implementation. No Node.js runtime or Playwright dependency required. (#754)
- **99x smaller install** -- Install size reduced from 710 MB to 7 MB by eliminating the Node.js dependency tree.
- **18x less memory** -- Daemon memory usage reduced from 143 MB to 8 MB.
- **1.6x faster cold start** -- Cold start time reduced from 1002ms to 617ms.
- **Benchmarks** -- Added benchmark suite comparing native vs Node.js daemon performance.
- **Chromium installer hardened** -- Fixed zip path traversal vulnerability in Chrome for Testing installer.
### Bug Fixes
- Fixed `--headed false` flag not being respected in CLI (#757)
- Fixed "not found" error pattern in `to_ai_friendly_error` incorrectly catching non-element errors (#759)
- Fixed storage local key lookup parsing and text output (#761)
- Fixed Lightpanda engine launch with release binaries (#760)
- Hardened Lightpanda startup timeouts (#762)
## 0.19.0
### Minor Changes
- 56bb92b: ### New Features
- **Browserless.io provider** -- Added browserless.io as a browser provider, supported in both Node.js and native daemon paths. Connect to remote Browserless instances with `--provider browserless` or `AGENT_BROWSER_PROVIDER=browserless`. Configurable via `BROWSERLESS_API_KEY`, `BROWSERLESS_API_URL`, and `BROWSERLESS_BROWSER_TYPE` environment variables. (#502, #746)
- **`clipboard` command** -- Read from and write to the browser clipboard. Supports `read`, `write <text>`, `copy` (simulates Ctrl+C), and `paste` (simulates Ctrl+V) operations. (#749)
- **Screenshot output configuration** -- New global flags `--screenshot-dir`, `--screenshot-quality`, `--screenshot-format` and corresponding `AGENT_BROWSER_SCREENSHOT_DIR`, `AGENT_BROWSER_SCREENSHOT_QUALITY`, `AGENT_BROWSER_SCREENSHOT_FORMAT` environment variables for persistent screenshot settings. (#749)
### Bug Fixes
- Fixed `wait --text` not working in native daemon path (#749)
- Fixed `BrowserManager.navigate()` and package entry point (#748)
- Fixed extensions not being loaded from `config.json` (#750)
- Fixed scroll on page load (#747)
- Fixed HTML retrieval by using `browser.getLocator()` for selector operations (#745)
## 0.18.0
### Minor Changes
- 942b8cd: ### New Features
- **`inspect` command** - Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Commands continue to work while DevTools is open. Implemented in both Node.js and native paths. (#736)
- **`get cdp-url` subcommand** - Retrieve the Chrome DevTools Protocol WebSocket URL for the active page, useful for external debugging tools. (#736)
- **Native screenshot annotate** - The `--annotate` flag for screenshots now works in the native Rust daemon, bringing parity with the Node.js path. (#706)
### Improvements
- **KERNEL_API_KEY now optional** - External credential injection no longer requires `KERNEL_API_KEY` to be set, making it easier to use Kernel with pre-configured environments. (#687)
- **Browserbase simplified** - Removed the `BROWSERBASE_PROJECT_ID` requirement, reducing setup friction for Browserbase users. (#625)
### Bug Fixes
- Fixed Browserbase API using incorrect endpoint to release sessions (#707)
- Fixed CDP connect paths using hardcoded 10s timeout instead of `getDefaultTimeout()` (#704)
- Fixed lone Unicode surrogates causing errors by sanitizing with `toWellFormed()` (#720)
- Fixed CDP connection failure on IPv6-first systems (#717)
- Fixed recordings not inheriting the current viewport settings (#718)
## 0.17.1
### Patch Changes
- 94cd888: Added support for device scale factor (retina display) in the viewport command via an optional scale parameter. Also added webview target type support for better Electron application compatibility, and the pages list now includes target type information.
## 0.17.0
### Minor Changes
- 94521e7: ### New Features
- **Lightpanda browser engine support** - Added `--engine <name>` flag to select the browser engine (`chrome` by default, or `lightpanda`), implying `--native` mode. Configurable via `AGENT_BROWSER_ENGINE` environment variable (#646)
- **Dialog dismiss command** - Added support for `dismiss` subcommand in dialog command parsing (#605)
### Improvements
- **Daemon startup error reporting** - Daemon startup errors are now surfaced directly instead of showing an opaque timeout message (#614)
- **CDP port discovery** - Replaced broken hand-rolled HTTP client with `reqwest` for more reliable CDP port discovery (#619)
- **Chrome extensions** - Extensions now load correctly by forcing headed mode when extensions are present (#652)
- **Google Translate bar suppression** - Suppressed the Google Translate bar in native headless mode to avoid interference (#649)
- **Auth cookie persistence** - Auth cookies are now persisted on browser close in native mode (#650)
### Bug Fixes
- Fixed native auth login failing due to incompatible encryption format (#648)
### Documentation
- Improved snapshot usage guidance and added reproducibility check (#630)
- Added `--engine` flag to the README options table
### Performance
- Added benchmarks to the CLI codebase (#637)
## 0.16.3
### Patch Changes
- 7d2c895: Fixed an issue where the --native flag was being passed to child processes even when not explicitly specified on the command line. The flag is now only forwarded when the user explicitly provides it, consistent with how other CLI flags like --allow-file-access and --download-path are handled.
## 0.16.2
### Patch Changes
- 01ac557: Added AGENT_BROWSER_HEADED environment variable support for running the browser in headed mode, and improved temporary profile cleanup when launching Chrome directly. Also includes documentation clarification that browser extensions work in both headed and headless modes.
## 0.16.1
### Patch Changes
- c4180c8: Improved Chrome launch reliability by automatically detecting containerized environments (Docker, Podman, Kubernetes) and enabling --no-sandbox when needed. Added support for discovering Playwright-installed Chromium browsers and enhanced error messages with helpful diagnostics when Chrome fails to launch.
## 0.16.0
### Minor Changes
- 05018b3: Added experimental native Rust daemon (`--native` flag, `AGENT_BROWSER_NATIVE=1` env, or `"native": true` in config). The native daemon communicates with Chrome directly via CDP, eliminating Node.js and Playwright dependencies. Supports 150+ commands with full parity to the default Node.js daemon. Includes WebDriver backend for Safari/iOS, CDP protocol codegen, request tracking, frame context management, and comprehensive e2e and parity tests.
## 0.15.3
### Patch Changes
- 62241b5: Fixed Windows compatibility issues including proper handling of extended-length path prefixes from canonicalize(), prevention of MSYS/Git Bash path translation that could mangle arguments, and improved daemon startup reliability. Also added ARM64 Windows support in postinstall shims and expanded CI testing with a full daemon lifecycle test on Windows.
## 0.15.2
### Patch Changes
- 6aea316: Documentation site improvements and internal tooling updates including enhanced code blocks, mobile navigation, and docs chat components. CLI connection and output handling refinements. Skill creator reference documentation and scripts have been reorganized.
## 0.15.1
### Patch Changes
- 7bd8ce9: Added support for chrome:// and chrome-extension:// URLs in navigation and recording commands. These special browser URLs are now preserved as-is instead of having https:// incorrectly prepended.
## 0.15.0
### Minor Changes
- 2e38882: - Added security hardening: authentication vault, content boundary markers, domain allowlist, action policy, action confirmation, and output length limits.
- Added `--download-path` flag (and `AGENT_BROWSER_DOWNLOAD_PATH` env / `downloadPath` config key) to set a default download directory.
- Added `--selector` flag to `scroll` command for scrolling within specific container elements.
## 0.14.0
@@ -205,6 +730,7 @@
### Minor Changes
- 316e649: ## New Features
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure via `-p` flag or `AGENT_BROWSER_PROVIDER` env var
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts with `--profile`
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket URL (e.g., `--cdp "wss://..."`)
@@ -213,6 +739,7 @@
- **Enhanced skills** - Hierarchical structure with references and templates for Claude Code
## Bug Fixes
- Screenshot command now supports refs and has improved error messages
- WebSocket URLs work in `connect` command
- Fixed socket file location (uses `~/.agent-browser` instead of TMPDIR)
@@ -220,6 +747,7 @@
- State load and path-based actions now show correct output messages
## Documentation
- Added Claude Code marketplace plugin installation instructions
- Updated skill documentation with references and templates
- Improved error documentation
+389 -248
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Vercel Sandbox credentials
SANDBOX_VERCEL_TOKEN=
SANDBOX_VERCEL_TEAM_ID=
SANDBOX_VERCEL_PROJECT_ID=
+2
View File
@@ -0,0 +1,2 @@
node_modules/
results.json
+76
View File
@@ -0,0 +1,76 @@
# agent-browser Daemon Benchmarks
Compares command latency and system metrics between the **Node.js daemon** (published npm version) and the **Rust native daemon** (built from source), running inside a [Vercel Sandbox](https://vercel.com/docs/sandbox) microVM.
## What it measures
**Command latency** -- per-scenario timing with warmup, multiple iterations, and stddev:
- `navigate` -- page load round-trip
- `snapshot` -- accessibility tree generation
- `screenshot` -- viewport capture
- `evaluate` -- JavaScript execution
- `click` -- element interaction
- `fill` -- form input
- `agent-loop` -- snapshot/click/snapshot cycle (typical AI agent pattern)
- `full-workflow` -- realistic 7-command sequence
**System metrics** -- collected while the daemon is running:
- Cold start time (daemon spawn + browser launch)
- Binary size and total distribution size (including browser download)
- Daemon RSS and peak RSS (separated from browser process memory)
- Browser RSS (Chrome processes, same for both daemons)
- Daemon CPU time
- Process counts
## Prerequisites
- Node.js 18+
- pnpm
- Vercel Sandbox credentials (token, team ID, project ID)
## Setup
```bash
cd benchmarks
pnpm install
cp .env.example .env
```
Fill in your Vercel Sandbox credentials in `.env`:
```
SANDBOX_VERCEL_TOKEN=your_token
SANDBOX_VERCEL_TEAM_ID=your_team_id
SANDBOX_VERCEL_PROJECT_ID=your_project_id
```
## Usage
```bash
pnpm bench # 10 iterations, 1 warmup, 8 vCPUs
pnpm bench -- --iterations 20 # more iterations for tighter stats
pnpm bench -- --warmup 2 # extra warmup iterations
pnpm bench -- --json # write results.json
pnpm bench -- --branch main # build native from a different branch
pnpm bench -- --vcpus 16 # more vCPUs (faster Rust build)
```
## How it works
1. Creates a Vercel Sandbox (Amazon Linux, configurable vCPUs)
2. Installs Chromium system dependencies
3. **Phase 1 -- Node.js daemon**: installs `agent-browser` from npm (last version with the Node daemon), runs all scenarios, collects metrics
4. **Phase 2 -- Rust native daemon**: installs Rust toolchain, clones the repo, runs `cargo build --release`, replaces the binary, runs the same scenarios, collects metrics
5. Prints comparison tables and optionally writes `results.json`
## Interpreting results
**Command latency** is dominated by Chrome (CDP round-trips), not the daemon. Both daemons are thin relays between the CLI and Chrome, so per-command speedups are typically small. The stddev column helps distinguish real differences from noise.
**Where the native daemon wins** is in cold start (no Node.js runtime to boot), daemon memory (single Rust binary vs V8 heap), and distribution size (no Playwright dependency).
The **daemon RSS** metric isolates the daemon process memory from Chrome. This is the apples-to-apples comparison -- both daemons talk to the same Chrome, but Node.js adds ~140 MB of V8 overhead while the Rust daemon uses ~7 MB.
**Distribution size** includes the daemon plus its browser download. The Node version includes the npm package + Playwright's bundled Chromium. The Rust version is just the binary + Chrome for Testing.
+900
View File
@@ -0,0 +1,900 @@
/**
* Node.js Daemon vs Rust Native Daemon benchmark.
*
* Compares the last published npm version (Node.js daemon) against the
* Rust-only build from a given branch, running real agent-browser commands
* inside a Vercel Sandbox.
*
* Captures:
* - Command latency (per-scenario, with warmup + measured iterations + stddev)
* - Cold start time (first launch to daemon ready)
* - Daemon memory (RSS, peak RSS) separated from browser memory
* - Daemon CPU time
* - Process tree (daemon + browser children)
* - Binary and distribution size on disk
*
* Usage:
* pnpm bench # default: 10 iterations, 1 warmup
* pnpm bench -- --iterations 20 # override iterations
* pnpm bench -- --warmup 2 # override warmup count
* pnpm bench -- --json # write results.json
* pnpm bench -- --branch my-branch # override native branch (default: ctate/native-2)
* pnpm bench -- --vcpus 8 # sandbox vCPUs (default: 8, higher = faster Rust build)
*/
import { Sandbox } from "@vercel/sandbox";
import { readFileSync, writeFileSync } from "fs";
import { scenarios, type Scenario } from "./scenarios.js";
// ---------------------------------------------------------------------------
// Env
// ---------------------------------------------------------------------------
function loadEnv() {
try {
const content = readFileSync(".env", "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq);
let val = trimmed.slice(eq + 1);
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
process.env[key] = val;
}
} catch {}
}
loadEnv();
const credentials = {
token: process.env.SANDBOX_VERCEL_TOKEN!,
teamId: process.env.SANDBOX_VERCEL_TEAM_ID!,
projectId: process.env.SANDBOX_VERCEL_PROJECT_ID!,
};
if (!credentials.token || !credentials.teamId || !credentials.projectId) {
console.error(
"Missing credentials. Set SANDBOX_VERCEL_TOKEN, SANDBOX_VERCEL_TEAM_ID, SANDBOX_VERCEL_PROJECT_ID in .env",
);
process.exit(1);
}
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
let iterations = 10;
let warmup = 1;
let json = false;
let branch = "ctate/native-2";
let vcpus = 8;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--iterations" && args[i + 1]) {
iterations = parseInt(args[++i], 10);
} else if (args[i] === "--warmup" && args[i + 1]) {
warmup = parseInt(args[++i], 10);
} else if (args[i] === "--json") {
json = true;
} else if (args[i] === "--branch" && args[i + 1]) {
branch = args[++i];
} else if (args[i] === "--vcpus" && args[i + 1]) {
vcpus = parseInt(args[++i], 10);
}
}
return { iterations, warmup, json, branch, vcpus };
}
const config = parseArgs();
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TIMEOUT_MS = 30 * 60 * 1000;
const REPO_URL = "https://github.com/vercel-labs/agent-browser.git";
const CHROMIUM_SYSTEM_DEPS = [
"nss",
"nspr",
"libxkbcommon",
"atk",
"at-spi2-atk",
"at-spi2-core",
"libXcomposite",
"libXdamage",
"libXrandr",
"libXfixes",
"libXcursor",
"libXi",
"libXtst",
"libXScrnSaver",
"libXext",
"mesa-libgbm",
"libdrm",
"mesa-libGL",
"mesa-libEGL",
"cups-libs",
"alsa-lib",
"pango",
"cairo",
"gtk3",
"dbus-libs",
];
// ---------------------------------------------------------------------------
// Sandbox helpers
// ---------------------------------------------------------------------------
type SandboxInstance = InstanceType<typeof Sandbox>;
async function run(
sandbox: SandboxInstance,
cmd: string,
args: string[],
): Promise<string> {
const result = await sandbox.runCommand(cmd, args);
const stdout = await result.stdout();
const stderr = await result.stderr();
if (result.exitCode !== 0) {
throw new Error(
`Command failed (exit ${result.exitCode}): ${cmd} ${args.join(" ")}\n${stderr || stdout}`,
);
}
return stdout;
}
async function shell(sandbox: SandboxInstance, script: string): Promise<string> {
return run(sandbox, "sh", ["-c", script]);
}
async function shellSafe(sandbox: SandboxInstance, script: string): Promise<string> {
const result = await sandbox.runCommand("sh", ["-c", script]);
return (await result.stdout()).trim();
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
interface Stats {
avgMs: number;
stddevMs: number;
minMs: number;
maxMs: number;
p50Ms: number;
samples: number[];
}
function computeStats(samples: number[]): Stats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const avg = sum / sorted.length;
const variance =
sorted.reduce((acc, v) => acc + (v - avg) ** 2, 0) / sorted.length;
return {
avgMs: Math.round(avg),
stddevMs: Math.round(Math.sqrt(variance)),
minMs: sorted[0],
maxMs: sorted[sorted.length - 1],
p50Ms: sorted[Math.floor(sorted.length / 2)],
samples: sorted,
};
}
// ---------------------------------------------------------------------------
// Metrics collection
// ---------------------------------------------------------------------------
interface ProcessMetrics {
pid: number;
rssKb: number;
vszKb: number;
cpuPercent: number;
memPercent: number;
cpuTimeSec: number;
command: string;
}
interface DaemonMetrics {
coldStartMs: number;
binarySizeBytes: number;
distributionSizeBytes: number;
daemonProcesses: ProcessMetrics[];
browserProcesses: ProcessMetrics[];
daemonRssKb: number;
browserRssKb: number;
daemonPeakRssKb: number;
daemonCpuTimeSec: number;
totalCpuTimeSec: number;
}
async function findDaemonPids(
sandbox: SandboxInstance,
_session: string,
): Promise<number[]> {
// The daemon process name is "agent-browser" but session/daemon flags are
// env vars, not command-line args, so we can't grep them from `ps`.
// Instead, find all agent-browser processes that look like long-running daemons
// (not short-lived CLI invocations -- those exit immediately).
const raw = await shellSafe(
sandbox,
`pgrep -x agent-browser 2>/dev/null || true`,
);
if (!raw) {
// Fallback: broader match on process name
const fallback = await shellSafe(
sandbox,
`pgrep -f 'agent-browser' 2>/dev/null | head -5 || true`,
);
if (!fallback) return [];
return fallback.split("\n").map(Number).filter(Boolean);
}
return raw.split("\n").map(Number).filter(Boolean);
}
async function collectProcessMetrics(
sandbox: SandboxInstance,
pid: number,
): Promise<ProcessMetrics | null> {
const raw = await shellSafe(
sandbox,
`ps -p ${pid} -o pid=,rss=,vsz=,%cpu=,%mem=,cputime=,comm= 2>/dev/null || true`,
);
if (!raw) return null;
const parts = raw.trim().split(/\s+/);
if (parts.length < 7) return null;
// Parse cputime "HH:MM:SS" or "MM:SS" to seconds
const timeParts = parts[5].split(":").map(Number);
let cpuTimeSec = 0;
if (timeParts.length === 3) {
cpuTimeSec = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
} else if (timeParts.length === 2) {
cpuTimeSec = timeParts[0] * 60 + timeParts[1];
}
return {
pid: Number(parts[0]),
rssKb: Number(parts[1]),
vszKb: Number(parts[2]),
cpuPercent: Number(parts[3]),
memPercent: Number(parts[4]),
cpuTimeSec,
command: parts.slice(6).join(" "),
};
}
async function getPeakRssKb(
sandbox: SandboxInstance,
pid: number,
): Promise<number> {
const raw = await shellSafe(
sandbox,
`cat /proc/${pid}/status 2>/dev/null | grep VmHWM | awk '{print $2}' || echo 0`,
);
return Number(raw) || 0;
}
async function getChildPids(
sandbox: SandboxInstance,
pid: number,
): Promise<number[]> {
const raw = await shellSafe(
sandbox,
`pgrep -P ${pid} 2>/dev/null || true`,
);
if (!raw) return [];
return raw.split("\n").map(Number).filter(Boolean);
}
async function getAllDescendantPids(
sandbox: SandboxInstance,
pid: number,
): Promise<number[]> {
const all: number[] = [];
const queue = [pid];
while (queue.length > 0) {
const current = queue.shift()!;
all.push(current);
const children = await getChildPids(sandbox, current);
queue.push(...children);
}
return all;
}
async function collectDaemonMetrics(
sandbox: SandboxInstance,
session: string,
coldStartMs: number,
binarySizeBytes: number,
distributionSizeBytes: number,
): Promise<DaemonMetrics> {
// Find daemon PIDs -- the agent-browser process itself
const daemonPids = await findDaemonPids(sandbox, session);
// Also find the full process tree (daemon + Chrome children)
let allPids: number[] = [];
for (const pid of daemonPids) {
const descendants = await getAllDescendantPids(sandbox, pid);
allPids.push(...descendants);
}
allPids = [...new Set(allPids)];
// If no daemon PIDs found via pgrep, fall back to grabbing all
// agent-browser and chrome processes for metrics
if (allPids.length === 0) {
const fallback = await shellSafe(
sandbox,
`ps -eo pid,comm | grep -E 'agent-browser|chrome' | grep -v grep | awk '{print $1}' || true`,
);
if (fallback) {
allPids = fallback.split("\n").map(Number).filter(Boolean);
}
}
const daemonProcs: ProcessMetrics[] = [];
const browserProcs: ProcessMetrics[] = [];
let daemonPeakRssKb = 0;
for (const pid of allPids) {
const metrics = await collectProcessMetrics(sandbox, pid);
if (!metrics) continue;
const isBrowser = /chrome|chromium/i.test(metrics.command);
if (isBrowser) {
browserProcs.push(metrics);
} else {
daemonProcs.push(metrics);
const peak = await getPeakRssKb(sandbox, pid);
daemonPeakRssKb = Math.max(daemonPeakRssKb, peak);
}
}
const daemonRssKb = daemonProcs.reduce((sum, p) => sum + p.rssKb, 0);
const browserRssKb = browserProcs.reduce((sum, p) => sum + p.rssKb, 0);
const daemonCpuTimeSec = daemonProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
const allProcs = [...daemonProcs, ...browserProcs];
const totalCpuTimeSec = allProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
return {
coldStartMs,
binarySizeBytes,
distributionSizeBytes,
daemonProcesses: daemonProcs,
browserProcesses: browserProcs,
daemonRssKb,
browserRssKb,
daemonPeakRssKb,
daemonCpuTimeSec,
totalCpuTimeSec,
};
}
async function getBinarySize(
sandbox: SandboxInstance,
): Promise<number> {
// Follow symlinks to get the real binary/script size
const raw = await shellSafe(
sandbox,
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
);
return Number(raw) || 0;
}
async function getDistributionSize(
sandbox: SandboxInstance,
mode: DaemonMode,
): Promise<number> {
if (mode === "node") {
// Total size of the npm package + Playwright browser
const npmPkg = await shellSafe(
sandbox,
`du -sb "$(npm root -g)/agent-browser" 2>/dev/null | awk '{print $1}' || echo 0`,
);
const pwBrowser = await shellSafe(
sandbox,
`du -sb "$HOME/.cache/ms-playwright" 2>/dev/null | awk '{print $1}' || echo 0`,
);
return (Number(npmPkg) || 0) + (Number(pwBrowser) || 0);
} else {
// Rust binary + Chrome for Testing (checks multiple possible cache paths)
const binary = await shellSafe(
sandbox,
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
);
const chrome = await shellSafe(
sandbox,
[
`size=0`,
`for d in "$HOME/.cache/agent-browser" "$HOME/.cache/ms-playwright" "$HOME/.agent-browser/chrome"; do`,
` if [ -d "$d" ]; then size=$(du -sb "$d" 2>/dev/null | awk '{print $1}'); break; fi`,
`done`,
`echo $size`,
].join("; "),
);
return (Number(binary) || 0) + (Number(chrome) || 0);
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function formatKb(kb: number): string {
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
return `${kb} KB`;
}
// ---------------------------------------------------------------------------
// Scenario runner
// ---------------------------------------------------------------------------
type DaemonMode = "node" | "native";
function daemonEnv(mode: DaemonMode): Record<string, string> {
return { AGENT_BROWSER_SESSION: `bench-${mode}` };
}
async function agentBrowser(
sandbox: SandboxInstance,
args: string[],
mode: DaemonMode,
): Promise<void> {
const result = await sandbox.runCommand({
cmd: "agent-browser",
args,
env: daemonEnv(mode),
});
if (result.exitCode !== 0) {
const stderr = await result.stderr();
const stdout = await result.stdout();
throw new Error(
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
);
}
}
async function timedAgentBrowser(
sandbox: SandboxInstance,
args: string[],
mode: DaemonMode,
): Promise<number> {
const start = Date.now();
const result = await sandbox.runCommand({
cmd: "agent-browser",
args,
env: daemonEnv(mode),
});
const elapsed = Date.now() - start;
if (result.exitCode !== 0) {
const stderr = await result.stderr();
const stdout = await result.stdout();
throw new Error(
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
);
}
return elapsed;
}
interface ScenarioResult {
name: string;
description: string;
stats: Stats;
error?: string;
}
async function runScenario(
sandbox: SandboxInstance,
scenario: Scenario,
mode: DaemonMode,
iterations: number,
warmup: number,
): Promise<ScenarioResult> {
try {
if (scenario.setup) {
for (const cmd of scenario.setup) {
await agentBrowser(sandbox, cmd, mode);
}
}
for (let w = 0; w < warmup; w++) {
for (const cmd of scenario.commands) {
await agentBrowser(sandbox, cmd, mode);
}
}
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
let totalMs = 0;
for (const cmd of scenario.commands) {
totalMs += await timedAgentBrowser(sandbox, cmd, mode);
}
samples.push(totalMs);
}
if (scenario.teardown) {
for (const cmd of scenario.teardown) {
await agentBrowser(sandbox, cmd, mode);
}
}
return {
name: scenario.name,
description: scenario.description,
stats: computeStats(samples),
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return {
name: scenario.name,
description: scenario.description,
stats: { avgMs: -1, stddevMs: -1, minMs: -1, maxMs: -1, p50Ms: -1, samples: [] },
error: message,
};
}
}
// ---------------------------------------------------------------------------
// Benchmark phases
// ---------------------------------------------------------------------------
interface DaemonResults {
mode: DaemonMode;
label: string;
scenarios: ScenarioResult[];
metrics: DaemonMetrics;
}
async function benchmarkDaemon(
sandbox: SandboxInstance,
mode: DaemonMode,
label: string,
): Promise<DaemonResults> {
console.log(`\n--- ${label} ---`);
// Measure sizes before launch
const binarySizeBytes = await getBinarySize(sandbox);
const distributionSizeBytes = await getDistributionSize(sandbox, mode);
// Cold start: time the first launch (daemon spawn + browser launch)
const coldStartBegin = Date.now();
await agentBrowser(sandbox, ["open", "about:blank"], mode);
const coldStartMs = Date.now() - coldStartBegin;
console.log(` Cold start: ${coldStartMs}ms`);
console.log(` Binary size: ${formatBytes(binarySizeBytes)}`);
console.log(` Distribution size: ${formatBytes(distributionSizeBytes)}`);
// Run all scenarios
const results: ScenarioResult[] = [];
for (const scenario of scenarios) {
process.stdout.write(` ${scenario.name} `);
const result = await runScenario(
sandbox,
scenario,
mode,
config.iterations,
config.warmup,
);
if (result.error) {
console.log(`FAILED: ${result.error.slice(0, 120)}`);
} else {
const dots = ".".repeat(Math.max(1, 30 - scenario.name.length));
const s = result.stats;
console.log(
`${dots} ${s.avgMs}ms avg +/-${s.stddevMs}ms (p50: ${s.p50Ms}ms, min: ${s.minMs}ms, max: ${s.maxMs}ms)`,
);
}
results.push(result);
}
// Collect system metrics after scenarios (daemon is still running)
const session = `bench-${mode}`;
const metrics = await collectDaemonMetrics(
sandbox,
session,
coldStartMs,
binarySizeBytes,
distributionSizeBytes,
);
// Also grab a full process snapshot for context
const psOutput = await shellSafe(
sandbox,
`ps aux --sort=-rss | head -20`,
);
console.log(`\n Process snapshot (top by RSS):`);
for (const line of psOutput.split("\n").slice(0, 10)) {
console.log(` ${line}`);
}
console.log(`\n Daemon processes (${metrics.daemonProcesses.length}):`);
console.log(` RSS: ${formatKb(metrics.daemonRssKb)} (peak: ${formatKb(metrics.daemonPeakRssKb)})`);
console.log(` CPU time: ${metrics.daemonCpuTimeSec.toFixed(1)}s`);
for (const p of metrics.daemonProcesses) {
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
}
console.log(` Browser processes (${metrics.browserProcesses.length}):`);
console.log(` RSS: ${formatKb(metrics.browserRssKb)}`);
for (const p of metrics.browserProcesses) {
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
}
await agentBrowser(sandbox, ["close"], mode);
console.log(` Browser closed.`);
return { mode, label, scenarios: results, metrics };
}
// ---------------------------------------------------------------------------
// Install helpers
// ---------------------------------------------------------------------------
async function installChromiumDeps(sandbox: SandboxInstance) {
console.log("Installing Chromium system dependencies...");
await shell(
sandbox,
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
);
}
async function installNodeDaemon(sandbox: SandboxInstance) {
console.log("Installing agent-browser from npm (Node.js daemon)...");
await run(sandbox, "npm", ["install", "-g", "agent-browser"]);
await run(sandbox, "npx", ["agent-browser", "install"]);
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
console.log(` version: ${version.trim()}`);
}
async function installNativeDaemon(sandbox: SandboxInstance, branch: string) {
console.log(`\nBuilding native daemon from ${branch}...`);
console.log(" Installing build tools and Rust toolchain...");
const rustStart = Date.now();
await shell(
sandbox,
"sudo dnf install -y gcc gcc-c++ make perl-core openssl-devel 2>&1",
);
await shell(
sandbox,
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 2>&1",
);
console.log(` Rust + build tools installed (${Math.round((Date.now() - rustStart) / 1000)}s)`);
console.log(` Cloning repo (branch: ${branch})...`);
const cloneStart = Date.now();
await shell(
sandbox,
`git clone --depth 1 --branch ${branch} ${REPO_URL} /tmp/agent-browser 2>&1`,
);
console.log(` Cloned (${Math.round((Date.now() - cloneStart) / 1000)}s)`);
console.log(" Building release binary (cargo build --release)...");
const buildStart = Date.now();
await shell(
sandbox,
"source $HOME/.cargo/env && cd /tmp/agent-browser/cli && cargo build --release 2>&1",
);
console.log(` Built (${Math.round((Date.now() - buildStart) / 1000)}s)`);
const npmBinPath = (await shell(sandbox, "which agent-browser")).trim();
console.log(` Replacing ${npmBinPath} with native build...`);
await shell(
sandbox,
`sudo cp /tmp/agent-browser/cli/target/release/agent-browser ${npmBinPath}`,
);
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
console.log(` version: ${version.trim()}`);
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
function printResults(node: DaemonResults, native: DaemonResults) {
console.log("\n\n========== COMMAND LATENCY ==========\n");
const header =
"Scenario".padEnd(20) + "| Node avg +/-sd | Rust avg +/-sd | Speedup";
const sep = "-".repeat(20) + "|-----------------|-----------------|--------";
console.log(header);
console.log(sep);
for (let i = 0; i < node.scenarios.length; i++) {
const n = node.scenarios[i];
const r = native.scenarios[i];
const name = n.name.padEnd(20);
if (n.error || r.error) {
const nodeVal = n.error ? "FAILED".padEnd(15) : `${n.stats.avgMs}ms`.padEnd(15);
const rustVal = r.error ? "FAILED".padEnd(15) : `${r.stats.avgMs}ms`.padEnd(15);
console.log(`${name}| ${nodeVal} | ${rustVal} | --`);
continue;
}
const nodeVal = `${n.stats.avgMs} +/-${n.stats.stddevMs}ms`.padEnd(15);
const rustVal = `${r.stats.avgMs} +/-${r.stats.stddevMs}ms`.padEnd(15);
const speedup =
r.stats.avgMs > 0
? (n.stats.avgMs / r.stats.avgMs).toFixed(2) + "x"
: "--";
console.log(`${name}| ${nodeVal} | ${rustVal} | ${speedup.padStart(6)}`);
}
console.log("\n\n========== SYSTEM METRICS ==========\n");
const nm = node.metrics;
const rm = native.metrics;
function ratio(a: number, b: number): string {
if (b <= 0) return "--";
return (a / b).toFixed(2) + "x";
}
const metricRows: [string, string, string, string][] = [
[
"Cold start",
`${nm.coldStartMs}ms`,
`${rm.coldStartMs}ms`,
ratio(nm.coldStartMs, rm.coldStartMs),
],
[
"Binary size",
formatBytes(nm.binarySizeBytes),
formatBytes(rm.binarySizeBytes),
ratio(nm.binarySizeBytes, rm.binarySizeBytes),
],
[
"Distribution size",
formatBytes(nm.distributionSizeBytes),
formatBytes(rm.distributionSizeBytes),
ratio(nm.distributionSizeBytes, rm.distributionSizeBytes),
],
[
"Daemon RSS",
formatKb(nm.daemonRssKb),
formatKb(rm.daemonRssKb),
ratio(nm.daemonRssKb, rm.daemonRssKb),
],
[
"Daemon peak RSS",
formatKb(nm.daemonPeakRssKb),
formatKb(rm.daemonPeakRssKb),
ratio(nm.daemonPeakRssKb, rm.daemonPeakRssKb),
],
[
"Browser RSS",
formatKb(nm.browserRssKb),
formatKb(rm.browserRssKb),
ratio(nm.browserRssKb, rm.browserRssKb),
],
[
"Daemon CPU time",
`${nm.daemonCpuTimeSec.toFixed(1)}s`,
`${rm.daemonCpuTimeSec.toFixed(1)}s`,
ratio(nm.daemonCpuTimeSec, rm.daemonCpuTimeSec),
],
[
"Daemon processes",
String(nm.daemonProcesses.length),
String(rm.daemonProcesses.length),
"--",
],
[
"Browser processes",
String(nm.browserProcesses.length),
String(rm.browserProcesses.length),
"--",
],
];
const mHeader =
"Metric".padEnd(20) + "| Node".padEnd(14) + "| Rust".padEnd(14) + "| Ratio";
const mSep = "-".repeat(20) + "|" + "-".repeat(13) + "|" + "-".repeat(13) + "|--------";
console.log(mHeader);
console.log(mSep);
for (const [metric, nodeVal, rustVal, ratio] of metricRows) {
console.log(
`${metric.padEnd(20)}| ${nodeVal.padEnd(12)}| ${rustVal.padEnd(12)}| ${ratio}`,
);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("agent-browser Daemon Benchmark (Node.js vs Rust Native)");
console.log(`Branch: ${config.branch}`);
console.log(`Iterations: ${config.iterations} (+ ${config.warmup} warmup)`);
console.log(`vCPUs: ${config.vcpus}\n`);
console.log("Creating sandbox...");
const sandbox = await Sandbox.create({
...credentials,
timeout: TIMEOUT_MS,
runtime: "node22",
networkPolicy: "allow-all" as const,
resources: { vcpus: config.vcpus },
});
console.log(`Sandbox: ${sandbox.sandboxId}`);
try {
await installChromiumDeps(sandbox);
// Phase 1: Node.js daemon (from published npm package)
await installNodeDaemon(sandbox);
const nodeResults = await benchmarkDaemon(
sandbox,
"node",
"Node.js Daemon (npm)",
);
// Phase 2: Rust native daemon (built from branch)
await installNativeDaemon(sandbox, config.branch);
const nativeResults = await benchmarkDaemon(
sandbox,
"native",
`Rust Native Daemon (${config.branch})`,
);
printResults(nodeResults, nativeResults);
if (config.json) {
const output = {
timestamp: new Date().toISOString(),
branch: config.branch,
vcpus: config.vcpus,
iterations: config.iterations,
warmup: config.warmup,
node: {
scenarios: nodeResults.scenarios.map((s) => ({
name: s.name,
description: s.description,
...s.stats,
error: s.error,
})),
metrics: nodeResults.metrics,
},
native: {
scenarios: nativeResults.scenarios.map((s) => ({
name: s.name,
description: s.description,
...s.stats,
error: s.error,
})),
metrics: nativeResults.metrics,
},
};
writeFileSync("results.json", JSON.stringify(output, null, 2));
console.log("\nResults written to results.json");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`\nFatal error: ${message}`);
process.exit(1);
} finally {
try {
await sandbox.stop();
console.log("\nSandbox stopped.");
} catch {
console.warn("Warning: failed to stop sandbox.");
}
}
}
main();
+13
View File
@@ -0,0 +1,13 @@
{
"name": "agent-browser-benchmarks",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"bench": "tsx bench.ts"
},
"dependencies": {
"@vercel/sandbox": "^1.8.0",
"tsx": "^4.19.0"
}
}
+472
View File
@@ -0,0 +1,472 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@vercel/sandbox':
specifier: ^1.8.0
version: 1.8.1
tsx:
specifier: ^4.19.0
version: 4.21.0
packages:
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vercel/sandbox@1.8.1':
resolution: {integrity: sha512-txohjI20aMxZiAzBL/KJi5EqTYsesBdOyIOtpTIyebPLTqYtDYfNhQ4OeYiUcPMUo0XBt8gSet/rIdLQEjj3/A==}
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
b4a@1.8.0:
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
jsonlines@0.1.1:
resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
os-paths@4.4.0:
resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
engines: {node: '>= 6.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
undici@7.24.1:
resolution: {integrity: sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==}
engines: {node: '>=20.18.1'}
xdg-app-paths@5.1.0:
resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==}
engines: {node: '>=6'}
xdg-portable@7.3.0:
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
engines: {node: '>= 6.0'}
zod@3.24.4:
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
snapshots:
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@vercel/oidc@3.2.0': {}
'@vercel/sandbox@1.8.1':
dependencies:
'@vercel/oidc': 3.2.0
async-retry: 1.3.3
jsonlines: 0.1.1
ms: 2.1.3
picocolors: 1.1.1
tar-stream: 3.1.7
undici: 7.24.1
xdg-app-paths: 5.1.0
zod: 3.24.4
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
async-retry@1.3.3:
dependencies:
retry: 0.13.1
b4a@1.8.0: {}
bare-events@2.8.2: {}
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
fast-fifo@1.3.2: {}
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
jsonlines@0.1.1: {}
ms@2.1.3: {}
os-paths@4.4.0: {}
picocolors@1.1.1: {}
resolve-pkg-maps@1.0.0: {}
retry@0.13.1: {}
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
tar-stream@3.1.7:
dependencies:
b4a: 1.8.0
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
text-decoder@1.2.7:
dependencies:
b4a: 1.8.0
transitivePeerDependencies:
- react-native-b4a
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
undici@7.24.1: {}
xdg-app-paths@5.1.0:
dependencies:
xdg-portable: 7.3.0
xdg-portable@7.3.0:
dependencies:
os-paths: 4.4.0
zod@3.24.4: {}
+105
View File
@@ -0,0 +1,105 @@
/**
* Benchmark scenarios for comparing Node.js daemon vs Rust native daemon.
*
* Each scenario defines CLI commands run via `sandbox.runCommand("agent-browser", args)`.
* Setup/teardown commands run once and are not timed.
* The `commands` array is timed over N iterations.
*/
export interface Scenario {
name: string;
description: string;
setup?: string[][];
commands: string[][];
teardown?: string[][];
}
const FORM_HTML = [
"<html><head><title>Bench</title></head><body>",
"<h1>Benchmark Page</h1>",
"<input id='name' type='text' placeholder='Name'>",
"<input id='email' type='email' placeholder='Email'>",
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
"<input id='agree' type='checkbox'>",
"<textarea id='bio' placeholder='Bio'></textarea>",
"<button id='submit'>Submit</button>",
"<p id='status'>Ready</p>",
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
"<ul>",
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
"</ul>",
"</body></html>",
].join("");
const INJECT_FORM_SCRIPT = `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`;
const SETUP_PAGE: string[][] = [
["open", "about:blank"],
["eval", INJECT_FORM_SCRIPT],
];
export const scenarios: Scenario[] = [
{
name: "navigate",
description: "Page navigation (about:blank round-trip)",
commands: [["open", "about:blank"]],
},
{
name: "snapshot",
description: "DOM snapshot (accessibility tree)",
setup: SETUP_PAGE,
commands: [["snapshot"]],
},
{
name: "screenshot",
description: "Screenshot capture",
setup: SETUP_PAGE,
commands: [["screenshot"]],
},
{
name: "evaluate",
description: "JavaScript evaluation",
setup: SETUP_PAGE,
commands: [
[
"eval",
"document.title + ' ' + document.querySelectorAll('li').length",
],
],
},
{
name: "click",
description: "Element click interaction",
setup: SETUP_PAGE,
commands: [["click", "#link"]],
},
{
name: "fill",
description: "Form field fill",
setup: SETUP_PAGE,
commands: [["fill", "#name", "Benchmark User"]],
},
{
name: "agent-loop",
description: "AI agent loop: snapshot -> click -> snapshot (typical agent cycle)",
setup: SETUP_PAGE,
commands: [["snapshot"], ["click", "#link"], ["snapshot"]],
},
{
name: "full-workflow",
description:
"Realistic workflow: navigate, inject form, snapshot, click, fill, evaluate, screenshot",
commands: [
["open", "about:blank"],
["eval", INJECT_FORM_SCRIPT],
["snapshot"],
["click", "#link"],
["fill", "#name", "Agent User"],
[
"eval",
"document.getElementById('name').value",
],
["screenshot"],
],
},
];
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true
},
"include": ["*.ts"]
}
+13 -2
View File
@@ -8,7 +8,7 @@
* binary directly (zero overhead).
*/
import { spawn } from 'child_process';
import { spawn, execSync } from 'child_process';
import { existsSync, accessSync, chmodSync, constants } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
@@ -16,6 +16,17 @@ import { platform, arch } from 'os';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Detect if the system uses musl libc (e.g. Alpine Linux)
function isMusl() {
if (platform() !== 'linux') return false;
try {
const result = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
return result.toLowerCase().includes('musl');
} catch {
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
}
}
// Map Node.js platform/arch to binary naming convention
function getBinaryName() {
const os = platform();
@@ -27,7 +38,7 @@ function getBinaryName() {
osKey = 'darwin';
break;
case 'linux':
osKey = 'linux';
osKey = isMusl() ? 'linux-musl' : 'linux';
break;
case 'win32':
osKey = 'win32';
+2698 -9
View File
File diff suppressed because it is too large Load Diff
+37 -12
View File
@@ -1,23 +1,39 @@
[package]
name = "agent-browser-stealth"
version = "0.14.0-fork.3"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
[[bin]]
name = "agent-browser"
path = "src/main.rs"
[[bin]]
name = "agent-browser-stealth"
path = "src/main_stealth.rs"
version = "0.24.0"
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"
readme = "../README.md"
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
categories = ["command-line-utilities", "web-programming"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "5.0"
base64 = "0.22"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
url = "2"
uuid = { version = "1", features = ["v4"] }
image = "0.25"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
sha2 = "0.10"
aes-gcm = "0.10"
async-trait = "0.1"
socket2 = "0.6"
similar = "2"
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
time = { version = "0.3", features = ["formatting"] }
hmac = "0.12"
hex = "0.4"
chrono = "0.4"
urlencoding = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -25,8 +41,17 @@ libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
[build-dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
[profile.ci]
inherits = "release"
lto = "thin"
codegen-units = 16
+481
View File
@@ -0,0 +1,481 @@
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::Path;
fn main() {
let protocol_dir = Path::new("cdp-protocol");
let out_dir = env::var("OUT_DIR").unwrap();
let out_path = Path::new(&out_dir).join("cdp_generated.rs");
let browser_path = protocol_dir.join("browser_protocol.json");
let js_path = protocol_dir.join("js_protocol.json");
if !browser_path.exists() && !js_path.exists() {
fs::write(
&out_path,
"// No protocol JSON files found in cdp-protocol/\n",
)
.unwrap();
return;
}
let mut all_domains: Vec<Domain> = Vec::new();
for path in [&browser_path, &js_path] {
if !path.exists() {
continue;
}
println!("cargo:rerun-if-changed={}", path.display());
let content = fs::read_to_string(path).unwrap();
let protocol: ProtocolSpec = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
eprintln!("cargo:warning=Failed to parse {}: {}", path.display(), e);
continue;
}
};
all_domains.extend(protocol.domains);
}
// Collect all known type IDs per domain for cross-domain resolution
let mut domain_types: std::collections::HashMap<String, HashSet<String>> =
std::collections::HashMap::new();
for domain in &all_domains {
let mut types = HashSet::new();
for td in &domain.types {
types.insert(td.id.clone());
}
domain_types.insert(domain.domain.clone(), types);
}
// Known recursive struct fields that need Box wrapping
let recursive_fields: HashSet<(&str, &str, &str)> = [
("DOM", "Node", "contentDocument"),
("DOM", "Node", "templateContent"),
("DOM", "Node", "importedDocument"),
("Accessibility", "AXNode", "sources"),
("Runtime", "StackTrace", "parent"),
]
.into_iter()
.collect();
let mut output = String::new();
output.push_str("use serde::{Deserialize, Serialize};\n\n");
for domain in &all_domains {
generate_domain(domain, &domain_types, &recursive_fields, &mut output);
}
fs::write(&out_path, &output).unwrap();
}
#[allow(dead_code)]
#[derive(serde::Deserialize)]
struct ProtocolSpec {
domains: Vec<Domain>,
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Clone)]
struct Domain {
domain: String,
#[serde(default)]
types: Vec<TypeDef>,
#[serde(default)]
commands: Vec<Command>,
#[serde(default)]
events: Vec<Event>,
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Clone)]
struct TypeDef {
id: String,
#[serde(rename = "type", default)]
type_kind: String,
#[serde(default)]
properties: Vec<Property>,
#[serde(rename = "enum", default)]
enum_values: Vec<String>,
#[serde(default)]
description: Option<String>,
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Clone)]
struct Command {
name: String,
#[serde(default)]
parameters: Vec<Property>,
#[serde(default)]
returns: Vec<Property>,
#[serde(default)]
description: Option<String>,
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Clone)]
struct Event {
name: String,
#[serde(default)]
parameters: Vec<Property>,
#[serde(default)]
description: Option<String>,
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Clone)]
struct Property {
name: String,
#[serde(rename = "type", default)]
type_kind: Option<String>,
#[serde(rename = "$ref", default)]
ref_type: Option<String>,
#[serde(default)]
optional: bool,
#[serde(default)]
description: Option<String>,
#[serde(default)]
items: Option<Box<ItemType>>,
#[serde(rename = "enum", default)]
enum_values: Vec<String>,
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Clone)]
struct ItemType {
#[serde(rename = "type", default)]
type_kind: Option<String>,
#[serde(rename = "$ref", default)]
ref_type: Option<String>,
}
fn to_pascal_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize = true;
for c in s.chars() {
if c == '_' || c == '-' || c == '.' {
capitalize = true;
} else if capitalize {
result.push(c.to_ascii_uppercase());
capitalize = false;
} else {
result.push(c);
}
}
result
}
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c.is_uppercase() && i > 0 {
// Only insert underscore at transitions from lowercase to uppercase,
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
let prev_upper = chars[i - 1].is_uppercase();
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
if !prev_upper || next_lower {
result.push('_');
}
}
result.push(c.to_ascii_lowercase());
}
result
}
/// Resolve a $ref type reference. Cross-domain refs like "Page.FrameId" become
/// `super::cdp_page::FrameId`. Same-domain refs are used directly.
fn resolve_ref(
r: &str,
current_domain: &str,
domain_types: &std::collections::HashMap<String, HashSet<String>>,
) -> String {
let parts: Vec<&str> = r.split('.').collect();
if parts.len() == 2 {
let ref_domain = parts[0];
let ref_type = parts[1];
if ref_domain == current_domain {
to_pascal_case(ref_type)
} else {
// Check if this type actually exists in the referenced domain
if domain_types
.get(ref_domain)
.is_some_and(|t| t.contains(ref_type))
{
format!(
"super::cdp_{}::{}",
to_snake_case(ref_domain),
to_pascal_case(ref_type)
)
} else {
// Fall back to serde_json::Value for unknown cross-domain refs
"serde_json::Value".to_string()
}
}
} else {
to_pascal_case(r)
}
}
fn map_type_in_domain(
prop: &Property,
current_domain: &str,
domain_types: &std::collections::HashMap<String, HashSet<String>>,
) -> String {
if let Some(ref r) = prop.ref_type {
let type_name = resolve_ref(r, current_domain, domain_types);
if prop.optional {
format!("Option<{}>", type_name)
} else {
type_name
}
} else if let Some(ref t) = prop.type_kind {
let base = match t.as_str() {
"string" => "String".to_string(),
"integer" => "i64".to_string(),
"number" => "f64".to_string(),
"boolean" => "bool".to_string(),
"object" => "serde_json::Value".to_string(),
"any" => "serde_json::Value".to_string(),
"array" => {
if let Some(ref items) = prop.items {
let inner = if let Some(ref r) = items.ref_type {
resolve_ref(r, current_domain, domain_types)
} else {
match items.type_kind.as_deref().unwrap_or("any") {
"string" => "String".to_string(),
"integer" => "i64".to_string(),
"number" => "f64".to_string(),
"boolean" => "bool".to_string(),
_ => "serde_json::Value".to_string(),
}
};
format!("Vec<{}>", inner)
} else {
"Vec<serde_json::Value>".to_string()
}
}
_ => "serde_json::Value".to_string(),
};
if prop.optional {
format!("Option<{}>", base)
} else {
base
}
} else if prop.optional {
"Option<serde_json::Value>".to_string()
} else {
"serde_json::Value".to_string()
}
}
fn is_rust_keyword(s: &str) -> bool {
matches!(
s,
"type"
| "self"
| "Self"
| "super"
| "move"
| "ref"
| "fn"
| "mod"
| "use"
| "pub"
| "let"
| "mut"
| "const"
| "static"
| "if"
| "else"
| "for"
| "while"
| "loop"
| "match"
| "return"
| "break"
| "continue"
| "as"
| "in"
| "impl"
| "trait"
| "struct"
| "enum"
| "where"
| "async"
| "await"
| "dyn"
| "box"
| "yield"
| "override"
| "crate"
| "extern"
)
}
fn generate_domain(
domain: &Domain,
domain_types: &std::collections::HashMap<String, HashSet<String>>,
recursive_fields: &HashSet<(&str, &str, &str)>,
output: &mut String,
) {
let mod_name = to_snake_case(&domain.domain);
output.push_str(&format!(
"#[allow(dead_code, non_snake_case, non_camel_case_types, clippy::enum_variant_names)]\npub mod cdp_{} {{\n",
mod_name
));
output.push_str(" use super::*;\n\n");
for type_def in &domain.types {
if !type_def.enum_values.is_empty() {
// Deduplicate enum variants (some CDP enums have duplicated PascalCase forms)
let mut seen_variants = HashSet::new();
output.push_str(" #[derive(Debug, Clone, Serialize, Deserialize)]\n");
output.push_str(&format!(" pub enum {} {{\n", type_def.id));
for val in &type_def.enum_values {
let mut variant = to_pascal_case(val);
if variant == "Self" {
variant = "SelfValue".to_string();
}
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
variant = format!("V{}", variant);
}
if seen_variants.insert(variant.clone()) {
output.push_str(&format!(
" #[serde(rename = \"{}\")]\n {},\n",
val, variant
));
}
}
output.push_str(" }\n\n");
} else if type_def.type_kind == "object" && !type_def.properties.is_empty() {
output.push_str(
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
);
output.push_str(&format!(" pub struct {} {{\n", type_def.id));
for prop in &type_def.properties {
let field_name = to_snake_case(&prop.name);
let field_name = if is_rust_keyword(&field_name) {
format!("r#{}", field_name)
} else {
field_name
};
let mut rust_type = map_type_in_domain(prop, &domain.domain, domain_types);
// Wrap recursive fields in Box
if recursive_fields.contains(&(
domain.domain.as_str(),
type_def.id.as_str(),
prop.name.as_str(),
)) {
if rust_type.starts_with("Option<") {
let inner = &rust_type[7..rust_type.len() - 1];
rust_type = format!("Option<Box<{}>>", inner);
} else {
rust_type = format!("Box<{}>", rust_type);
}
}
if prop.optional {
output
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
}
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
}
output.push_str(" }\n\n");
} else if type_def.type_kind == "object" && type_def.properties.is_empty() {
output.push_str(&format!(
" pub type {} = serde_json::Value;\n\n",
type_def.id
));
} else if type_def.type_kind == "array" {
output.push_str(&format!(
" pub type {} = Vec<serde_json::Value>;\n\n",
type_def.id
));
} else if type_def.type_kind == "string" && type_def.enum_values.is_empty() {
output.push_str(&format!(" pub type {} = String;\n\n", type_def.id));
} else if type_def.type_kind == "integer" {
output.push_str(&format!(" pub type {} = i64;\n\n", type_def.id));
} else if type_def.type_kind == "number" {
output.push_str(&format!(" pub type {} = f64;\n\n", type_def.id));
}
}
for cmd in &domain.commands {
let pascal_name = to_pascal_case(&cmd.name);
if !cmd.parameters.is_empty() {
output.push_str(
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
);
output.push_str(&format!(" pub struct {}Params {{\n", pascal_name));
for param in &cmd.parameters {
let field_name = to_snake_case(&param.name);
let field_name = if is_rust_keyword(&field_name) {
format!("r#{}", field_name)
} else {
field_name
};
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
if param.optional {
output
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
}
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
}
output.push_str(" }\n\n");
}
if !cmd.returns.is_empty() {
output.push_str(
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
);
output.push_str(&format!(" pub struct {}Result {{\n", pascal_name));
for ret in &cmd.returns {
let field_name = to_snake_case(&ret.name);
let field_name = if is_rust_keyword(&field_name) {
format!("r#{}", field_name)
} else {
field_name
};
let rust_type = map_type_in_domain(ret, &domain.domain, domain_types);
if ret.optional {
output
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
}
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
}
output.push_str(" }\n\n");
}
}
for event in &domain.events {
if !event.parameters.is_empty() {
let pascal_name = to_pascal_case(&event.name);
output.push_str(
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
);
output.push_str(&format!(" pub struct {}Event {{\n", pascal_name));
for param in &event.parameters {
let field_name = to_snake_case(&param.name);
let field_name = if is_rust_keyword(&field_name) {
format!("r#{}", field_name)
} else {
field_name
};
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
if param.optional {
output
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
}
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
}
output.push_str(" }\n\n");
}
}
output.push_str("}\n\n");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+901 -195
View File
File diff suppressed because it is too large Load Diff
+235 -254
View File
@@ -26,6 +26,8 @@ pub struct Response {
pub success: bool,
pub data: Option<Value>,
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[allow(dead_code)]
@@ -120,6 +122,8 @@ fn get_pid_path(session: &str) -> PathBuf {
fn cleanup_stale_files(session: &str) {
let pid_path = get_pid_path(session);
let _ = fs::remove_file(&pid_path);
let stream_path = get_socket_dir().join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
#[cfg(unix)]
{
@@ -140,7 +144,7 @@ fn get_port_path(session: &str) -> PathBuf {
}
#[cfg(windows)]
fn get_port_for_session(session: &str) -> u16 {
pub fn get_port_for_session(session: &str) -> u16 {
let mut hash: i32 = 0;
for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
@@ -150,37 +154,19 @@ fn get_port_for_session(session: &str) -> u16 {
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
}
#[cfg(unix)]
fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(session);
if !pid_path.exists() {
return false;
}
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
unsafe {
return libc::kill(pid, 0) == 0;
}
}
}
false
}
/// Read the actual daemon port from the `.port` file written by the daemon.
/// Falls back to the hash-derived port if the file does not exist or is
/// unreadable (e.g. daemon has not started yet).
#[cfg(windows)]
fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(session);
if !pid_path.exists() {
return false;
}
let port = get_port_for_session(session);
TcpStream::connect_timeout(
&format!("127.0.0.1:{}", port).parse().unwrap(),
Duration::from_millis(100),
)
.is_ok()
pub fn resolve_port(session: &str) -> u16 {
let port_path = get_port_path(session);
fs::read_to_string(&port_path)
.ok()
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or_else(|| get_port_for_session(session))
}
fn daemon_ready(session: &str) -> bool {
pub fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
@@ -188,7 +174,7 @@ fn daemon_ready(session: &str) -> bool {
}
#[cfg(windows)]
{
let port = get_port_for_session(session);
let port = resolve_port(session);
TcpStream::connect_timeout(
&format!("127.0.0.1:{}", port).parse().unwrap(),
Duration::from_millis(50),
@@ -203,26 +189,128 @@ pub struct DaemonResult {
pub already_running: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn ensure_daemon(
session: &str,
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
args: Option<&str>,
user_agent: Option<&str>,
proxy: Option<&str>,
proxy_bypass: Option<&str>,
ignore_https_errors: bool,
allow_file_access: bool,
state: Option<&str>,
provider: Option<&str>,
device: Option<&str>,
session_name: Option<&str>,
debug: bool,
) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
/// Options forwarded to the daemon process as environment variables.
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
/// UX concern (prompting the user on stdin) and not a daemon configuration.
/// The daemon only needs `confirm_actions` to gate action categories.
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>,
pub proxy_bypass: Option<&'a str>,
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>,
pub allowed_domains: Option<&'a [String]>,
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,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
cmd.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
if opts.headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
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);
}
if let Some(ua) = opts.user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = opts.proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = opts.proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if let Some(pu) = opts.proxy_username {
cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu);
}
if let Some(pp) = opts.proxy_password {
cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp);
}
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);
}
if let Some(sn) = opts.session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(dp) = opts.download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(ad) = opts.allowed_domains {
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
}
if let Some(ap) = opts.action_policy {
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
}
if let Some(ca) = opts.confirm_actions {
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
}
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");
}
}
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
// Socket connectivity is the sole liveness check — no PID check — so
// callers in a different PID namespace (e.g. unshare) can still reuse
// an existing daemon they can reach over the socket.
if daemon_ready(session) {
// Double-check it's actually responsive by waiting and checking again
// This handles the race condition where daemon is shutting down
// (daemon has a 100ms shutdown delay, so we wait longer)
@@ -276,189 +364,54 @@ pub fn ensure_daemon(
}
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary)
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
#[allow(unused_assignments)]
let mut daemon_child: Option<std::process::Child> = None;
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
// Spawn daemon as a fully detached background process
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
// Create new session (detach from terminal)
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
for _ in 0..50 {
@@ -467,13 +420,47 @@ pub fn ensure_daemon(
already_running: false,
});
}
// Detect early daemon exit and surface the real error from stderr
if let Some(ref mut child) = daemon_child {
if let Ok(Some(_)) = child.try_wait() {
let mut stderr_output = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_output);
}
let stderr_trimmed = stderr_output.trim();
if !stderr_trimmed.is_empty() {
let msg = if stderr_trimmed.len() > 500 {
let mut end = 500;
while !stderr_trimmed.is_char_boundary(end) {
end -= 1;
}
&stderr_trimmed[..end]
} else {
stderr_trimmed
};
return Err(format!("Daemon process exited during startup:\n{}", msg));
}
return Err(
"Daemon process exited during startup with no error output. \
Re-run with --debug for more details."
.to_string(),
);
}
}
thread::sleep(Duration::from_millis(100));
}
Err(format!(
"Daemon failed to start (socket: {})",
#[cfg(unix)]
let endpoint_info = format!(
"socket: {}",
get_socket_dir().join(format!("{}.sock", session)).display()
))
);
#[cfg(windows)]
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
Err(format!("Daemon failed to start ({})", endpoint_info))
}
fn connect(session: &str) -> Result<Connection, String> {
@@ -486,7 +473,7 @@ fn connect(session: &str) -> Result<Connection, String> {
}
#[cfg(windows)]
{
let port = get_port_for_session(session);
let port = resolve_port(session);
TcpStream::connect(format!("127.0.0.1:{}", port))
.map(Connection::Tcp)
.map_err(|e| format!("Failed to connect: {}", e))
@@ -544,6 +531,8 @@ fn is_transient_error(error: &str) -> bool {
|| error.contains("os error 2") // No such file or directory (socket gone)
|| error.contains("os error 61") // Connection refused (macOS)
|| error.contains("os error 111") // Connection refused (Linux)
|| error.contains("os error 10061") // Connection refused (Windows)
|| error.contains("os error 10054") // Connection reset by peer (Windows)
}
fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
@@ -571,45 +560,14 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
// Mutex to prevent parallel tests from interfering with env vars
static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// RAII guard that locks env mutex and restores env vars on drop
struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
impl<'a> EnvGuard<'a> {
fn new(var_names: &[&str]) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
let vars = var_names
.iter()
.map(|&name| (name.to_string(), env::var(name).ok()))
.collect();
Self { _lock: lock, vars }
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
for (name, value) in &self.vars {
match value {
Some(v) => env::set_var(name, v),
None => env::remove_var(name),
}
}
}
}
use crate::test_utils::EnvGuard;
#[test]
fn test_get_socket_dir_explicit_override() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
env::remove_var("XDG_RUNTIME_DIR");
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
_guard.remove("XDG_RUNTIME_DIR");
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
}
@@ -618,8 +576,8 @@ mod tests {
fn test_get_socket_dir_ignores_empty_socket_dir() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
env::remove_var("XDG_RUNTIME_DIR");
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
_guard.remove("XDG_RUNTIME_DIR");
assert!(get_socket_dir()
.to_string_lossy()
@@ -630,8 +588,8 @@ mod tests {
fn test_get_socket_dir_xdg_runtime() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
assert_eq!(
get_socket_dir(),
@@ -643,8 +601,8 @@ mod tests {
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
env::set_var("XDG_RUNTIME_DIR", "");
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
_guard.set("XDG_RUNTIME_DIR", "");
assert!(get_socket_dir()
.to_string_lossy()
@@ -655,8 +613,8 @@ mod tests {
fn test_get_socket_dir_home_fallback() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
env::remove_var("XDG_RUNTIME_DIR");
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
_guard.remove("XDG_RUNTIME_DIR");
let result = get_socket_dir();
assert!(result.to_string_lossy().ends_with(".agent-browser"));
@@ -750,6 +708,20 @@ mod tests {
));
}
#[test]
fn test_is_transient_error_connection_refused_windows() {
assert!(is_transient_error(
"Failed to connect: No connection could be made because the target machine actively refused it. (os error 10061)"
));
}
#[test]
fn test_is_transient_error_connection_reset_windows() {
assert!(is_transient_error(
"Failed to send: An existing connection was forcibly closed by the remote host. (os error 10054)"
));
}
#[test]
fn test_is_transient_error_non_transient() {
// These should NOT be considered transient
@@ -758,4 +730,13 @@ mod tests {
assert!(!is_transient_error("Permission denied"));
assert!(!is_transient_error("Daemon not found"));
}
#[test]
#[cfg(windows)]
fn test_get_port_for_session() {
assert_eq!(get_port_for_session("default"), 50838);
assert_eq!(get_port_for_session("my-session"), 63105);
assert_eq!(get_port_for_session("work"), 51184);
assert_eq!(get_port_for_session(""), 49152);
}
}
+414 -54
View File
@@ -8,17 +8,59 @@ const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
/// Parse idle timeout from user-friendly format.
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
fn parse_idle_timeout(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Empty idle timeout".to_string());
}
// If the value ends with a unit suffix, convert it to milliseconds.
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
let ms = match unit {
"s" => num * 1000,
"m" => num * 60 * 1000,
"h" => num * 60 * 60 * 1000,
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
};
return Ok(ms.to_string());
}
// Pure numbers are already expressed in milliseconds.
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
Ok(s.to_string())
}
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
value.and_then(|raw| match parse_idle_timeout(&raw) {
Ok(ms) => Some(ms),
Err(e) => {
eprintln!(
"{} invalid idle timeout from {}: {}",
color::warning_indicator(),
source,
e
);
None
}
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Config {
pub headed: Option<bool>,
pub json: Option<bool>,
pub full: Option<bool>,
pub debug: Option<bool>,
pub session: Option<String>,
pub session_name: Option<String>,
pub executable_path: Option<String>,
pub extensions: Option<Vec<String>>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
@@ -33,6 +75,19 @@ pub struct Config {
pub headers: Option<String>,
pub annotate: Option<bool>,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: Option<bool>,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>,
pub no_auto_dialog: Option<bool>,
}
impl Config {
@@ -40,7 +95,6 @@ impl Config {
Config {
headed: other.headed.or(self.headed),
json: other.json.or(self.json),
full: other.full.or(self.full),
debug: other.debug.or(self.debug),
session: other.session.or(self.session),
session_name: other.session_name.or(self.session_name),
@@ -52,6 +106,7 @@ impl Config {
}
(a, b) => b.or(a),
},
profile: other.profile.or(self.profile),
state: other.state.or(self.state),
proxy: other.proxy.or(self.proxy),
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
@@ -66,6 +121,19 @@ impl Config {
headers: other.headers.or(self.headers),
annotate: other.annotate.or(self.annotate),
color_scheme: other.color_scheme.or(self.color_scheme),
download_path: other.download_path.or(self.download_path),
content_boundaries: other.content_boundaries.or(self.content_boundaries),
max_output: other.max_output.or(self.max_output),
allowed_domains: other.allowed_domains.or(self.allowed_domains),
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
engine: other.engine.or(self.engine),
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
screenshot_format: other.screenshot_format.or(self.screenshot_format),
idle_timeout: other.idle_timeout.or(self.idle_timeout),
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
}
}
}
@@ -73,7 +141,13 @@ impl Config {
fn read_config_file(path: &Path) -> Option<Config> {
let content = fs::read_to_string(path).ok()?;
match serde_json::from_str::<Config>(&content) {
Ok(config) => Some(config),
Ok(mut config) => {
config.idle_timeout = parse_idle_timeout_value(
config.idle_timeout.take(),
&format!("config file {}", path.display()),
);
Some(config)
}
Err(e) => {
eprintln!(
"{} invalid config file {}: {}",
@@ -112,6 +186,11 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
/// Extract --config <path> from args before full flag parsing.
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
/// was the last arg with no value, `None` if --config not present.
///
/// Only flags that consume a following argument need to be listed here.
/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are
/// intentionally absent -- they don't take a value, so they can't cause
/// the next argument to be mis-consumed.
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
const FLAGS_WITH_VALUE: &[&str] = &[
"--session",
@@ -130,7 +209,16 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--device",
"--session-name",
"--color-scheme",
"--channel",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--engine",
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
];
let mut i = 0;
while i < args.len() {
@@ -179,7 +267,6 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
pub struct Flags {
pub json: bool,
pub full: bool,
pub headed: bool,
pub debug: bool,
pub session: String,
@@ -187,6 +274,7 @@ pub struct Flags {
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
@@ -200,11 +288,25 @@ pub struct Flags {
pub session_name: Option<String>,
pub annotate: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: bool,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
pub no_auto_dialog: bool,
// 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,
@@ -212,6 +314,8 @@ pub struct Flags {
pub cli_proxy_bypass: bool,
pub cli_allow_file_access: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_headed: bool,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -238,11 +342,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
headed: match env::var("AGENT_BROWSER_HEADED") {
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
Err(_) => config.headed.unwrap_or(true),
},
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION")
.ok()
@@ -254,11 +354,22 @@ pub fn parse_flags(args: &[String]) -> Flags {
.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").ok().or(config.proxy),
proxy: env::var("AGENT_BROWSER_PROXY")
.ok()
.or(config.proxy)
.or_else(|| env::var("HTTP_PROXY").ok())
.or_else(|| env::var("http_proxy").ok())
.or_else(|| env::var("HTTPS_PROXY").ok())
.or_else(|| env::var("https_proxy").ok())
.or_else(|| env::var("ALL_PROXY").ok())
.or_else(|| env::var("all_proxy").ok()),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass),
.or(config.proxy_bypass)
.or_else(|| env::var("NO_PROXY").ok())
.or_else(|| env::var("no_proxy").ok()),
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
@@ -278,8 +389,54 @@ pub fn parse_flags(args: &[String]) -> Flags {
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
.ok()
.or(config.color_scheme),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
.ok()
.or(config.download_path),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
.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),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
.ok()
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
.ok()
.or(config.screenshot_dir),
screenshot_quality: env::var("AGENT_BROWSER_SCREENSHOT_QUALITY")
.ok()
.and_then(|s| s.parse().ok())
.or(config.screenshot_quality),
screenshot_format: env::var("AGENT_BROWSER_SCREENSHOT_FORMAT")
.ok()
.or(config.screenshot_format)
.filter(|s| s == "png" || s == "jpeg"),
idle_timeout: parse_idle_timeout_value(
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
)
.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,
@@ -287,6 +444,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_proxy_bypass: false,
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_headed: false,
};
let mut i = 0;
@@ -299,16 +458,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--full" | "-f" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.full = val;
if consumed {
i += 1;
}
}
"--headed" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val;
flags.cli_headed = true;
if consumed {
i += 1;
}
@@ -326,6 +479,19 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--idle-timeout" => {
if let Some(s) = args.get(i + 1) {
match parse_idle_timeout(s) {
Ok(ms) => flags.idle_timeout = Some(ms),
Err(e) => eprintln!(
"{} Invalid --idle-timeout: {}",
color::warning_indicator(),
e
),
}
i += 1;
}
}
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
@@ -352,6 +518,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--profile" => {
if let Some(s) = args.get(i + 1) {
flags.profile = Some(s.clone());
flags.cli_profile = true;
i += 1;
}
}
"--state" => {
if let Some(s) = args.get(i + 1) {
flags.state = Some(s.clone());
@@ -441,6 +614,107 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--download-path" => {
if let Some(s) = args.get(i + 1) {
flags.download_path = Some(s.clone());
flags.cli_download_path = true;
i += 1;
}
}
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed {
i += 1;
}
}
"--max-output" => {
if let Some(s) = args.get(i + 1) {
if let Ok(n) = s.parse::<usize>() {
flags.max_output = Some(n);
}
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());
i += 1;
}
}
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed {
i += 1;
}
}
"--engine" => {
if let Some(s) = args.get(i + 1) {
flags.engine = Some(s.clone());
i += 1;
}
}
"--screenshot-dir" => {
if let Some(s) = args.get(i + 1) {
flags.screenshot_dir = Some(s.clone());
i += 1;
}
}
"--screenshot-quality" => {
if let Some(s) = args.get(i + 1) {
if let Ok(n) = s.parse::<u32>() {
if n <= 100 {
flags.screenshot_quality = Some(n);
} else {
eprintln!(
"{} --screenshot-quality must be 0-100, got {}",
color::warning_indicator(),
n
);
}
}
i += 1;
}
}
"--screenshot-format" => {
if let Some(s) = args.get(i + 1) {
if s == "png" || s == "jpeg" {
flags.screenshot_format = Some(s.clone());
} else {
eprintln!(
"{} --screenshot-format must be png or jpeg, got '{}'",
color::warning_indicator(),
s
);
}
i += 1;
}
}
"--no-auto-dialog" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.no_auto_dialog = val;
if consumed {
i += 1;
}
}
"--config" => {
// Already handled by load_config(); skip the value
i += 1;
@@ -459,13 +733,15 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Boolean flags that optionally take true/false
const GLOBAL_BOOL_FLAGS: &[&str] = &[
"--json",
"--full",
"--headed",
"--debug",
"--ignore-https-errors",
"--allow-file-access",
"--auto-connect",
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--no-auto-dialog",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -474,6 +750,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--executable-path",
"--cdp",
"--extension",
"--profile",
"--state",
"--proxy",
"--proxy-bypass",
@@ -484,7 +761,17 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--device",
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--config",
"--engine",
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
];
let mut i = 0;
@@ -500,7 +787,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
i += 1;
continue;
}
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) || arg == "-f" {
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) {
if let Some(v) = args.get(i + 1) {
if matches!(v.as_str(), "true" | "false") {
i += 1;
@@ -529,6 +816,36 @@ mod tests {
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
}
#[test]
fn test_parse_idle_timeout_raw_ms() {
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
}
#[test]
fn test_parse_idle_timeout_seconds() {
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
}
#[test]
fn test_parse_idle_timeout_minutes() {
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
}
#[test]
fn test_parse_idle_timeout_hours() {
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
}
#[test]
fn test_parse_idle_timeout_rejects_capital_m() {
assert!(parse_idle_timeout("10M").is_err());
}
#[test]
fn test_parse_idle_timeout_rejects_unknown_unit() {
assert!(parse_idle_timeout("10x").is_err());
}
#[test]
fn test_parse_headers_flag_with_spaces() {
// Headers JSON is passed as a single quoted argument in shell
@@ -624,6 +941,18 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_idle_timeout_before_command() {
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_idle_timeout_flag_converts_to_ms() {
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
}
#[test]
fn test_parse_flags_with_session_and_executable_path() {
let flags = parse_flags(&args(
@@ -655,6 +984,12 @@ mod tests {
assert!(flags.cli_extensions);
}
#[test]
fn test_cli_profile_tracking() {
let flags = parse_flags(&args("--profile /path/to/profile snapshot"));
assert!(flags.cli_profile);
}
#[test]
fn test_cli_annotate_tracking() {
let flags = parse_flags(&args("--annotate screenshot"));
@@ -668,12 +1003,26 @@ mod tests {
assert!(!flags.cli_annotate);
}
#[test]
fn test_cli_download_path_tracking() {
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
assert!(flags.cli_download_path);
assert_eq!(flags.download_path, Some("/tmp/dl".to_string()));
}
#[test]
fn test_cli_download_path_not_set_without_flag() {
let flags = parse_flags(&args("snapshot"));
assert!(!flags.cli_download_path);
}
#[test]
fn test_cli_multiple_flags_tracking() {
let flags = parse_flags(&args(
"--executable-path /chrome --proxy http://proxy snapshot",
"--executable-path /chrome --profile /profile --proxy http://proxy snapshot",
));
assert!(flags.cli_executable_path);
assert!(flags.cli_profile);
assert!(flags.cli_proxy);
assert!(!flags.cli_extensions);
assert!(!flags.cli_state);
@@ -686,12 +1035,12 @@ mod tests {
let json = r#"{
"headed": true,
"json": true,
"full": true,
"debug": true,
"session": "test-session",
"sessionName": "my-app",
"executablePath": "/usr/bin/chromium",
"extensions": ["/ext1", "/ext2"],
"profile": "/tmp/profile",
"state": "/tmp/state.json",
"proxy": "http://proxy:8080",
"proxyBypass": "localhost",
@@ -708,7 +1057,6 @@ mod tests {
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
assert_eq!(config.json, Some(true));
assert_eq!(config.full, Some(true));
assert_eq!(config.debug, Some(true));
assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app"));
@@ -717,6 +1065,7 @@ mod tests {
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
@@ -762,6 +1111,7 @@ mod tests {
let user = Config {
headed: Some(true),
proxy: Some("http://user-proxy:8080".to_string()),
profile: Some("/user/profile".to_string()),
..Config::default()
};
let project = Config {
@@ -772,6 +1122,7 @@ mod tests {
let merged = user.merge(project);
assert_eq!(merged.headed, Some(true)); // kept from user
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090")); // overridden by project
assert_eq!(merged.profile.as_deref(), Some("/user/profile")); // kept from user
assert_eq!(merged.debug, Some(true)); // added by project
}
@@ -805,6 +1156,22 @@ mod tests {
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_from_file_parses_idle_timeout() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-idle-timeout-config");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("test-config.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap();
let config = read_config_file(&config_path).unwrap();
assert_eq!(config.idle_timeout.as_deref(), Some("10000"));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_missing_file_returns_none() {
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
@@ -965,36 +1332,6 @@ mod tests {
assert!(!flags.auto_connect);
}
#[test]
fn test_full_bare_defaults_true() {
let flags = parse_flags(&args("--full open example.com"));
assert!(flags.full);
}
#[test]
fn test_full_false() {
let flags = parse_flags(&args("--full false open example.com"));
assert!(!flags.full);
}
#[test]
fn test_full_short_flag() {
let flags = parse_flags(&args("-f open example.com"));
assert!(flags.full);
}
#[test]
fn test_clean_args_removes_full_with_value() {
let cleaned = clean_args(&args("--full false open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_short_full() {
let cleaned = clean_args(&args("-f open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_bool_flag_with_value() {
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
@@ -1051,4 +1388,27 @@ mod tests {
let merged = user.merge(project);
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
}
#[test]
fn test_no_auto_dialog_flag() {
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
assert!(flags.no_auto_dialog);
}
#[test]
fn test_no_auto_dialog_default_false() {
let flags = parse_flags(&args("open example.com"));
assert!(!flags.no_auto_dialog);
}
#[test]
fn test_clean_args_removes_no_auto_dialog() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--no-auto-dialog".to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
}
+757 -157
View File
@@ -1,167 +1,416 @@
use crate::color;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{exit, Command, Stdio};
const LAST_KNOWN_GOOD_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
pub fn get_browsers_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agent-browser")
.join("browsers")
}
pub fn find_installed_chrome() -> Option<PathBuf> {
let browsers_dir = get_browsers_dir();
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] home_dir={:?} browsers_dir={}",
dirs::home_dir(),
browsers_dir.display()
);
}
if !browsers_dir.exists() {
if debug {
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
}
return None;
}
let entries = match fs::read_dir(&browsers_dir) {
Ok(entries) => entries,
Err(e) => {
let _ = writeln!(
io::stderr(),
"Warning: cannot read Chrome cache directory {}: {}",
browsers_dir.display(),
e
);
return None;
}
};
let mut versions: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let matches = e
.file_name()
.to_str()
.is_some_and(|n| n.starts_with("chrome-"));
if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] entry {:?} matches={}",
e.file_name(),
matches
);
}
matches
})
.collect();
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for entry in versions {
let dir = entry.path();
if let Some(bin) = chrome_binary_in_dir(&dir) {
let exists = bin.exists();
if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] candidate {} exists={}",
bin.display(),
exists
);
}
if exists {
return Some(bin);
}
} else if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] no binary found in {}",
dir.display()
);
}
}
if debug {
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
}
None
}
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let app =
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if app.exists() {
return Some(app);
}
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if inner.exists() {
return Some(inner);
}
let inner_x64 = dir.join(
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
);
if inner_x64.exists() {
return Some(inner_x64);
}
None
}
#[cfg(target_os = "linux")]
{
let bin = dir.join("chrome");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-linux64/chrome");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(target_os = "windows")]
{
let bin = dir.join("chrome.exe");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-win64/chrome.exe");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}
fn platform_key() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"mac-arm64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"mac-x64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux64"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"win64"
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
{
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
// can still be used for other commands like `connect`. The install path
// guards against this at runtime before calling platform_key().
panic!("Unsupported platform for Chrome for Testing download")
}
}
async fn fetch_download_url() -> Result<(String, String), String> {
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
.await
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse version info: {}", e))?;
let channel = body
.get("channels")
.and_then(|c| c.get("Stable"))
.ok_or("No Stable channel found in version info")?;
let version = channel
.get("version")
.and_then(|v| v.as_str())
.ok_or("No version string found")?
.to_string();
let platform = platform_key();
let url = channel
.get("downloads")
.and_then(|d| d.get("chrome"))
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter().find_map(|entry| {
if entry.get("platform")?.as_str()? == platform {
Some(entry.get("url")?.as_str()?.to_string())
} else {
None
}
})
})
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
Ok((version, url))
}
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
let resp = reqwest::get(url)
.await
.map_err(|e| format!("Download failed: {}", e))?;
let total = resp.content_length();
let mut bytes = Vec::new();
let mut stream = resp;
let mut downloaded: u64 = 0;
let mut last_pct: u64 = 0;
loop {
let chunk = stream
.chunk()
.await
.map_err(|e| format!("Download error: {}", e))?;
match chunk {
Some(data) => {
downloaded += data.len() as u64;
bytes.extend_from_slice(&data);
if let Some(total) = total {
let pct = (downloaded * 100) / total;
if pct >= last_pct + 5 {
last_pct = pct;
let mb = downloaded as f64 / 1_048_576.0;
let total_mb = total as f64 / 1_048_576.0;
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
let _ = io::stderr().flush();
}
}
}
None => break,
}
}
eprintln!();
Ok(bytes)
}
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
let cursor = io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let enclosed = match file.enclosed_name() {
Some(name) => name.to_owned(),
None => continue,
};
let raw_name = enclosed.to_string_lossy().to_string();
// Strip the top-level "chrome-<platform>/" directory from zip entries.
// On Windows, enclosed_name() normalizes paths to backslashes, so we
// must split on either separator.
let rel_path = raw_name
.strip_prefix("chrome-")
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or(raw_name.clone());
if rel_path.is_empty() {
continue;
}
let out_path = dest.join(&rel_path);
// Defense-in-depth: ensure the resolved path is inside dest
if !out_path.starts_with(dest) {
continue;
}
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create parent dir {}: {}", parent.display(), e)
})?;
}
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
io::copy(&mut file, &mut out_file)
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
}
}
}
}
Ok(())
}
pub fn run_install(with_deps: bool) {
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
eprintln!(
"{} Chrome for Testing does not provide Linux ARM64 builds.",
color::error_indicator()
);
eprintln!(" Install Chromium from your system package manager instead:");
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
eprintln!(" sudo dnf install chromium # Fedora");
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
exit(1);
}
let is_linux = cfg!(target_os = "linux");
if is_linux {
if with_deps {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
"libxcb-shm0",
"libx11-xcb1",
"libx11-6",
"libxcb1",
"libxext6",
"libxrandr2",
"libxcomposite1",
"libxcursor1",
"libxdamage1",
"libxfixes3",
"libxi6",
"libgtk-3-0",
"libpangocairo-1.0-0",
"libpango-1.0-0",
"libatk1.0-0",
"libcairo-gobject2",
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
libasound,
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
"libnss3",
"libnspr4",
"libatk-bridge2.0-0",
"libdrm2",
"libxkbcommon0",
"libatspi2.0-0",
"libcups2",
"libxshmfence1",
"libgbm1",
],
)
} else if which_exists("dnf") {
(
"dnf",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
"libxcb",
"libX11-xcb",
"libX11",
"libXext",
"libXcursor",
"libXfixes",
"libXi",
"gtk3",
"cairo-gobject",
],
)
} else if which_exists("yum") {
(
"yum",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
],
)
} else {
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
let install_cmd = match pkg_mgr {
"apt-get" => {
format!(
"sudo apt-get update && sudo apt-get install -y {}",
deps.join(" ")
)
}
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
};
println!("Running: {}", install_cmd);
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
match status {
Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator())
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
}
install_linux_deps();
} else {
println!(
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("{}", color::cyan("Installing Chromium browser..."));
println!("{}", color::cyan("Installing Chrome..."));
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
// Pass the entire command as a single string to /c to handle paths with spaces.
#[cfg(windows)]
let status = Command::new("cmd")
.args(["/c", "npx playwright install chromium"])
.status();
#[cfg(not(windows))]
let status = Command::new("npx")
.args(["playwright", "install", "chromium"])
.status();
match status {
Ok(s) if s.success() => {
println!(
"{} Chromium installed successfully",
color::success_indicator()
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!(
"{} Failed to create runtime: {}",
color::error_indicator(),
e
);
exit(1);
});
let (version, url) = match rt.block_on(fetch_download_url()) {
Ok(v) => v,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
let dest = get_browsers_dir().join(format!("chrome-{}", version));
if let Some(bin) = chrome_binary_in_dir(&dest) {
if bin.exists() {
println!(
"{} Chrome {} is already installed",
color::success_indicator(),
version
);
return;
}
}
println!(" Downloading Chrome {} for {}", version, platform_key());
println!(" {}", url);
let bytes = match rt.block_on(download_bytes(&url)) {
Ok(b) => b,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
match extract_zip(bytes, &dest) {
Ok(()) => {
println!(
"{} Chrome {} installed successfully",
color::success_indicator(),
version
);
println!(" Location: {}", dest.display());
if is_linux && !with_deps {
println!();
println!(
@@ -171,25 +420,255 @@ pub fn run_install(with_deps: bool) {
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux {
println!(
"{} Try installing system dependencies first:",
color::yellow("Tip:")
);
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
let _ = fs::remove_dir_all(&dest);
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
}
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
match status {
Ok(s) if s.success() => {
println!(
"{} System dependencies installed",
color::success_indicator()
)
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!(
"{} Could not run install command: {}",
color::warning_indicator(),
e
),
}
}
fn install_linux_deps() {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
// part of the 64-bit time_t transition. Using the old names can cause
// apt to propose removing hundreds of system packages to resolve
// conflicts. We check for the t64 variant first to avoid this.
let apt_deps: Vec<&str> = vec![
("libxcb-shm0", None),
("libx11-xcb1", None),
("libx11-6", None),
("libxcb1", None),
("libxext6", None),
("libxrandr2", None),
("libxcomposite1", None),
("libxcursor1", None),
("libxdamage1", None),
("libxfixes3", None),
("libxi6", None),
("libgtk-3-0", Some("libgtk-3-0t64")),
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
("libpango-1.0-0", Some("libpango-1.0-0t64")),
("libatk1.0-0", Some("libatk1.0-0t64")),
("libcairo-gobject2", Some("libcairo-gobject2t64")),
("libcairo2", Some("libcairo2t64")),
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
("libxrender1", None),
("libasound2", Some("libasound2t64")),
("libfreetype6", None),
("libfontconfig1", None),
("libdbus-1-3", Some("libdbus-1-3t64")),
("libnss3", None),
("libnspr4", None),
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
("libdrm2", None),
("libxkbcommon0", None),
("libatspi2.0-0", Some("libatspi2.0-0t64")),
("libcups2", Some("libcups2t64")),
("libxshmfence1", None),
("libgbm1", None),
// Fonts: without actual font files, pages render with missing glyphs
// (tofu). This is especially visible for CJK and emoji characters.
("fonts-noto-color-emoji", None),
("fonts-noto-cjk", None),
("fonts-freefont-ttf", None),
]
.into_iter()
.map(|(base, t64_variant)| {
if let Some(t64) = t64_variant {
if package_exists_apt(t64) {
return t64;
}
}
base
})
.collect();
("apt-get", apt_deps)
} else if which_exists("dnf") {
(
"dnf",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
"libxcb",
"libX11-xcb",
"libX11",
"libXext",
"libXcursor",
"libXfixes",
"libXi",
"gtk3",
"cairo-gobject",
// Fonts
"google-noto-cjk-fonts",
"google-noto-emoji-color-fonts",
"liberation-fonts",
],
)
} else if which_exists("yum") {
(
"yum",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
// Fonts
"google-noto-cjk-fonts",
"liberation-fonts",
],
)
} else {
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
if pkg_mgr == "apt-get" {
// Run apt-get update first
println!("Running: sudo apt-get update");
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
match update_status {
Ok(s) if !s.success() => {
eprintln!(
"{} apt-get update failed. Continuing with existing package lists.",
color::warning_indicator()
);
}
Err(e) => {
eprintln!(
"{} Could not run apt-get update: {}",
color::warning_indicator(),
e
);
}
_ => {}
}
// Simulate the install first to detect if apt would remove any
// packages. This prevents the catastrophic scenario where installing
// these libraries triggers removal of hundreds of system packages
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
// t64 transition).
println!("Checking for conflicts...");
let sim_output = Command::new("sudo")
.args(["apt-get", "install", "--simulate"])
.args(&deps)
.output();
match sim_output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}\n{}", stdout, stderr);
// Count packages that would be removed
let removals: Vec<&str> = combined
.lines()
.filter(|line| line.starts_with("Remv "))
.collect();
if !removals.is_empty() {
eprintln!(
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
color::error_indicator(),
removals.len()
);
eprintln!(
" This usually means some package names have changed on your system"
);
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
eprintln!();
eprintln!(" Packages that would be removed:");
for line in removals.iter().take(20) {
eprintln!(" {}", line);
}
if removals.len() > 20 {
eprintln!(" ... and {} more", removals.len() - 20);
}
eprintln!();
eprintln!(" To install dependencies manually, run:");
eprintln!(" sudo apt-get install {}", deps.join(" "));
eprintln!();
eprintln!(" Review the apt output carefully before confirming.");
exit(1);
}
}
Err(e) => {
eprintln!(
"{} Could not simulate install ({}). Proceeding with caution.",
color::warning_indicator(),
e
);
}
}
// Safe to proceed: no removals detected
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
println!("Running: {}", install_cmd);
let status = Command::new("sudo")
.args(["apt-get", "install", "-y"])
.args(&deps)
.status();
report_install_status(status);
} else {
// dnf / yum path — these package managers do not remove packages
// during install, so the simulate-first guard is not needed.
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
println!("Running: {}", install_cmd);
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
report_install_status(status);
}
}
fn which_exists(cmd: &str) -> bool {
#[cfg(unix)]
{
@@ -223,3 +702,124 @@ fn package_exists_apt(pkg: &str) -> bool {
.map(|s| s.success())
.unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Dashboard install
// ---------------------------------------------------------------------------
pub fn get_dashboard_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.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<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
let cursor = io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let enclosed = match file.enclosed_name() {
Some(name) => name.to_owned(),
None => continue,
};
let 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(())
}
+911 -236
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
include!("main.rs");
File diff suppressed because it is too large Load Diff
+556
View File
@@ -0,0 +1,556 @@
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthProfile {
pub name: String,
pub url: String,
pub username: String,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username_selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub submit_selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<String>,
}
// Keep legacy Credential alias for backward compatibility
pub type Credential = AuthProfile;
fn validate_profile_name(name: &str) -> Result<(), String> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!(
"Invalid profile name '{}'. Must match /^[a-zA-Z0-9_-]+$/",
name
));
}
Ok(())
}
fn get_auth_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("auth")
} else {
std::env::temp_dir().join("agent-browser").join("auth")
}
}
fn get_profile_path(name: &str) -> PathBuf {
get_auth_dir().join(format!("{}.json", name))
}
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
const KEY_FILE_NAME: &str = ".encryption-key";
fn get_agent_browser_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser")
} else {
std::env::temp_dir().join("agent-browser")
}
}
fn get_key_file_path() -> PathBuf {
get_agent_browser_dir().join(KEY_FILE_NAME)
}
fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
let hex_str = hex_str.trim();
if hex_str.len() != 64 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let bytes: Vec<u8> = (0..32)
.map(|i| u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).unwrap())
.collect();
Some(bytes)
}
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
fn get_encryption_key() -> Result<Vec<u8>, String> {
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
return parse_key_hex(&key_hex).ok_or_else(|| {
format!(
"{} should be a 64-character hex string (256 bits). Generate one with: openssl rand -hex 32",
ENCRYPTION_KEY_ENV
)
});
}
let key_file = get_key_file_path();
if key_file.exists() {
let hex = fs::read_to_string(&key_file)
.map_err(|e| format!("Failed to read encryption key file: {}", e))?;
return parse_key_hex(&hex).ok_or_else(|| {
format!(
"Invalid encryption key in {}. Expected 64-character hex string.",
key_file.display()
)
});
}
Err(format!(
"Encryption key required. Set {} or ensure {} exists.",
ENCRYPTION_KEY_ENV,
key_file.display()
))
}
/// Ensure an encryption key exists, auto-generating one if needed.
fn ensure_encryption_key() -> Result<Vec<u8>, String> {
if let Ok(key) = get_encryption_key() {
return Ok(key);
}
let mut key = [0u8; 32];
getrandom::getrandom(&mut key).map_err(|e| format!("Failed to generate key: {}", e))?;
let key_hex = key.iter().map(|b| format!("{:02x}", b)).collect::<String>();
let dir = get_agent_browser_dir();
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
}
let key_file = get_key_file_path();
fs::write(&key_file, format!("{}\n", key_hex))
.map_err(|e| format!("Failed to write encryption key: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&key_file, fs::Permissions::from_mode(0o600));
}
let _ = writeln!(
std::io::stderr(),
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
key_file.display(),
ENCRYPTION_KEY_ENV
);
Ok(key.to_vec())
}
/// Encrypt a profile to the JSON+base64 format compatible with Node.js.
fn encrypt_profile(profile: &AuthProfile) -> Result<String, String> {
let key = ensure_encryption_key()?;
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
let plaintext = serde_json::to_string(profile)
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
let mut iv = [0u8; 12];
getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
// aes_gcm appends the 16-byte auth tag to the ciphertext
let encrypted = cipher
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?;
let tag_offset = encrypted.len() - 16;
let ciphertext = &encrypted[..tag_offset];
let auth_tag = &encrypted[tag_offset..];
let payload = json!({
"version": 1,
"encrypted": true,
"iv": STANDARD.encode(iv),
"authTag": STANDARD.encode(auth_tag),
"data": STANDARD.encode(ciphertext),
});
serde_json::to_string_pretty(&payload)
.map_err(|e| format!("Failed to serialize payload: {}", e))
}
/// JSON envelope written by Node.js encryption (src/encryption.ts).
#[derive(Deserialize)]
struct EncryptedPayload {
#[allow(dead_code)]
version: u32,
#[allow(dead_code)]
encrypted: bool,
iv: String,
#[serde(rename = "authTag")]
auth_tag: String,
data: String,
}
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
let text = std::str::from_utf8(data).map_err(|_| {
"Profile is not valid UTF-8 -- it may use an older incompatible binary format".to_string()
})?;
if let Ok(payload) = serde_json::from_str::<EncryptedPayload>(text) {
let key = get_encryption_key()?;
let iv = STANDARD
.decode(&payload.iv)
.map_err(|e| format!("Invalid base64 iv: {}", e))?;
let auth_tag = STANDARD
.decode(&payload.auth_tag)
.map_err(|e| format!("Invalid base64 authTag: {}", e))?;
let ciphertext = STANDARD
.decode(&payload.data)
.map_err(|e| format!("Invalid base64 data: {}", e))?;
// aes_gcm expects ciphertext || auth_tag as input to decrypt
let mut combined = Vec::with_capacity(ciphertext.len() + auth_tag.len());
combined.extend_from_slice(&ciphertext);
combined.extend_from_slice(&auth_tag);
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
.map_err(|e| format!("Decryption failed: {}", e))?;
let json_str = String::from_utf8(plaintext)
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
}
// Fallback: try as plain unencrypted JSON profile
serde_json::from_str::<AuthProfile>(text)
.map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
}
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
let dir = get_auth_dir();
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create auth dir: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
}
let encrypted_json = encrypt_profile(profile)?;
let path = get_profile_path(&profile.name);
fs::write(&path, &encrypted_json).map_err(|e| format!("Failed to write profile: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
fn load_profile(name: &str) -> Result<AuthProfile, String> {
let path = get_profile_path(name);
if !path.exists() {
return Err(format!("Auth profile '{}' not found", name));
}
let data = fs::read(&path).map_err(|e| format!("Failed to read profile: {}", e))?;
decrypt_profile(&data)
}
pub fn credentials_set(
name: &str,
username: &str,
password: &str,
url: Option<&str>,
) -> Result<Value, String> {
validate_profile_name(name)?;
let profile = AuthProfile {
name: name.to_string(),
url: url.unwrap_or("").to_string(),
username: username.to_string(),
password: password.to_string(),
username_selector: None,
password_selector: None,
submit_selector: None,
created_at: None,
last_login_at: None,
};
save_profile(&profile)?;
Ok(json!({ "saved": name }))
}
pub fn auth_save(
name: &str,
url: &str,
username: &str,
password: &str,
username_selector: Option<&str>,
password_selector: Option<&str>,
submit_selector: Option<&str>,
) -> Result<Value, String> {
validate_profile_name(name)?;
let profile = AuthProfile {
name: name.to_string(),
url: url.to_string(),
username: username.to_string(),
password: password.to_string(),
username_selector: username_selector.map(String::from),
password_selector: password_selector.map(String::from),
submit_selector: submit_selector.map(String::from),
created_at: None,
last_login_at: None,
};
save_profile(&profile)?;
Ok(json!({ "saved": name }))
}
pub fn credentials_get(name: &str) -> Result<Value, String> {
let profile = load_profile(name)?;
Ok(json!({
"name": profile.name,
"username": profile.username,
"url": profile.url,
"hasPassword": true,
}))
}
pub fn credentials_get_full(name: &str) -> Result<AuthProfile, String> {
load_profile(name)
}
pub fn credentials_delete(name: &str) -> Result<Value, String> {
validate_profile_name(name)?;
let path = get_profile_path(name);
if !path.exists() {
return Err(format!("Auth profile '{}' not found", name));
}
fs::remove_file(&path).map_err(|e| format!("Failed to delete profile: {}", e))?;
Ok(json!({ "deleted": name }))
}
pub fn credentials_list() -> Result<Value, String> {
let dir = get_auth_dir();
if !dir.exists() {
return Ok(json!({ "profiles": [] }));
}
let mut profiles = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
match load_profile(&name) {
Ok(profile) => {
profiles.push(json!({
"name": profile.name,
"username": profile.username,
"url": profile.url,
}));
}
Err(_) => {
profiles.push(json!({
"name": name,
"error": "Failed to decrypt",
}));
}
}
}
}
Ok(json!({ "profiles": profiles }))
}
pub fn auth_show(name: &str) -> Result<Value, String> {
validate_profile_name(name)?;
let profile = load_profile(name)?;
Ok(json!({
"profile": {
"name": profile.name,
"url": profile.url,
"username": profile.username,
"usernameSelector": profile.username_selector,
"passwordSelector": profile.password_selector,
"submitSelector": profile.submit_selector,
}
}))
}
#[cfg(test)]
pub(crate) static AUTH_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
fn with_test_key<F: FnOnce()>(f: F) {
let _lock = AUTH_TEST_MUTEX.lock().unwrap();
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
let test_key = "a".repeat(64);
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, &test_key) };
f();
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
match original {
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
}
}
#[test]
fn test_validate_profile_name() {
assert!(validate_profile_name("github").is_ok());
assert!(validate_profile_name("my-app").is_ok());
assert!(validate_profile_name("test_123").is_ok());
assert!(validate_profile_name("").is_err());
assert!(validate_profile_name("has space").is_err());
assert!(validate_profile_name("../evil").is_err());
assert!(validate_profile_name("foo/bar").is_err());
}
#[test]
fn test_auth_profile_serialization() {
let profile = AuthProfile {
name: "test".to_string(),
url: "https://example.com".to_string(),
username: "user".to_string(),
password: "pass".to_string(),
username_selector: None,
password_selector: None,
submit_selector: Some("button[type=submit]".to_string()),
created_at: None,
last_login_at: None,
};
let json = serde_json::to_string(&profile).unwrap();
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test");
assert_eq!(
parsed.submit_selector,
Some("button[type=submit]".to_string())
);
assert!(parsed.username_selector.is_none());
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
with_test_key(|| {
let profile = AuthProfile {
name: "roundtrip".to_string(),
url: "https://example.com".to_string(),
username: "user".to_string(),
password: "s3cret!".to_string(),
username_selector: None,
password_selector: None,
submit_selector: None,
created_at: None,
last_login_at: None,
};
let encrypted_json = encrypt_profile(&profile).unwrap();
let decrypted = decrypt_profile(encrypted_json.as_bytes()).unwrap();
assert_eq!(decrypted.name, "roundtrip");
assert_eq!(decrypted.password, "s3cret!");
});
}
#[test]
fn test_get_encryption_key_from_env() {
with_test_key(|| {
let key = get_encryption_key().unwrap();
assert_eq!(key.len(), 32);
assert!(key.iter().all(|&b| b == 0xaa));
});
}
#[test]
fn test_parse_key_hex_valid() {
let hex = "ab".repeat(32);
let key = parse_key_hex(&hex).unwrap();
assert_eq!(key.len(), 32);
assert!(key.iter().all(|&b| b == 0xab));
}
#[test]
fn test_parse_key_hex_invalid() {
assert!(parse_key_hex("too_short").is_none());
assert!(parse_key_hex(&"g".repeat(64)).is_none());
assert!(parse_key_hex("").is_none());
}
#[test]
fn test_decrypt_json_payload_format() {
with_test_key(|| {
let key = get_encryption_key().unwrap();
let profile = AuthProfile {
name: "json-test".to_string(),
url: "https://example.com/login".to_string(),
username: "admin".to_string(),
password: "hunter2".to_string(),
username_selector: Some("#email".to_string()),
password_selector: None,
submit_selector: None,
created_at: None,
last_login_at: None,
};
// Encrypt with aes_gcm, then manually build the JSON payload
// to simulate what Node.js would produce
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
let mut iv = [0u8; 12];
getrandom::getrandom(&mut iv).unwrap();
let plaintext = serde_json::to_string(&profile).unwrap();
let encrypted = cipher
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
.unwrap();
let tag_offset = encrypted.len() - 16;
let ciphertext = &encrypted[..tag_offset];
let auth_tag = &encrypted[tag_offset..];
let payload = format!(
r#"{{"version":1,"encrypted":true,"iv":"{}","authTag":"{}","data":"{}"}}"#,
STANDARD.encode(iv),
STANDARD.encode(auth_tag),
STANDARD.encode(ciphertext),
);
let decrypted = decrypt_profile(payload.as_bytes()).unwrap();
assert_eq!(decrypted.name, "json-test");
assert_eq!(decrypted.password, "hunter2");
assert_eq!(decrypted.username_selector, Some("#email".to_string()));
});
}
#[test]
fn test_encrypted_output_is_json_format() {
with_test_key(|| {
let profile = AuthProfile {
name: "format-check".to_string(),
url: "https://example.com".to_string(),
username: "user".to_string(),
password: "pass".to_string(),
username_selector: None,
password_selector: None,
submit_selector: None,
created_at: None,
last_login_at: None,
};
let encrypted = encrypt_profile(&profile).unwrap();
let parsed: Value = serde_json::from_str(&encrypted).unwrap();
assert_eq!(parsed["version"], 1);
assert_eq!(parsed["encrypted"], true);
assert!(parsed["iv"].is_string());
assert!(parsed["authTag"].is_string());
assert!(parsed["data"].is_string());
});
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+361
View File
@@ -0,0 +1,361 @@
use std::collections::HashMap;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::{broadcast, oneshot, Mutex};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tokio_tungstenite::tungstenite::Message;
use super::types::{CdpCommand, CdpEvent, CdpMessage};
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
/// Interval between WebSocket ping frames sent to keep the connection alive
/// through intermediate proxies (reverse proxies, load balancers, service meshes).
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
/// Raw incoming CDP message (text) broadcast to all subscribers.
/// Used by the inspect proxy to forward responses and events to DevTools.
#[derive(Debug, Clone)]
pub struct RawCdpMessage {
pub text: String,
pub session_id: Option<String>,
}
pub struct CdpClient {
ws_tx: Arc<
Mutex<
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>,
>,
>,
next_id: AtomicU64,
pending: PendingMap,
event_tx: broadcast::Sender<CdpEvent>,
raw_tx: broadcast::Sender<RawCdpMessage>,
_reader_handle: tokio::task::JoinHandle<()>,
_keepalive_handle: tokio::task::JoinHandle<()>,
}
impl CdpClient {
pub async fn connect(url: &str) -> Result<Self, String> {
Self::connect_with_headers(url, None).await
}
pub async fn connect_with_headers(
url: &str,
headers: Option<Vec<(String, String)>>,
) -> Result<Self, String> {
let mut request = url
.into_client_request()
.map_err(|e| format!("Invalid WebSocket URL: {}", e))?;
if let Some(hdrs) = headers {
let req_headers = request.headers_mut();
for (key, value) in hdrs {
if let (Ok(name), Ok(val)) = (
key.parse::<tokio_tungstenite::tungstenite::http::header::HeaderName>(),
value.parse::<tokio_tungstenite::tungstenite::http::header::HeaderValue>(),
) {
req_headers.insert(name, val);
}
}
}
let ws_config = WebSocketConfig {
max_message_size: None,
max_frame_size: None,
..Default::default()
};
let (ws_stream, _) =
tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false)
.await
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
enable_tcp_keepalive(ws_stream.get_ref());
let (ws_tx, mut ws_rx) = ws_stream.split();
let ws_tx = Arc::new(Mutex::new(ws_tx));
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
let (event_tx, _) = broadcast::channel(256);
let (raw_tx, _) = broadcast::channel(512);
let pending_clone = pending.clone();
let event_tx_clone = event_tx.clone();
let raw_tx_clone = raw_tx.clone();
// Notify used to stop the keepalive task when the reader loop exits.
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
let reader_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
// Accept both Text and Binary frames — remote CDP proxies
// (e.g. Browserless) may send responses as Binary frames.
let msg = match msg {
Ok(Message::Text(text)) => text,
Ok(Message::Binary(data)) => match String::from_utf8(data) {
Ok(text) => text,
Err(_) => continue,
},
Ok(Message::Close(frame)) => {
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
let reason = frame
.as_ref()
.map(|f| format!("code={}, reason={}", f.code, f.reason))
.unwrap_or_else(|| "no frame".to_string());
let _ =
writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
}
break;
}
Ok(Message::Pong(_)) => continue,
Ok(_) => continue,
Err(e) => {
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
}
break;
}
};
// Broadcast raw message for inspect proxy subscribers before typed parse,
// so messages with negative IDs (used by the inspect proxy) are still delivered.
if raw_tx_clone.receiver_count() > 0 {
let session_id = serde_json::from_str::<serde_json::Value>(&msg)
.ok()
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
let _ = raw_tx_clone.send(RawCdpMessage {
text: msg.clone(),
session_id,
});
}
let parsed: CdpMessage = match serde_json::from_str(&msg) {
Ok(m) => m,
// Expected for inspect proxy messages with negative IDs
// (CdpMessage.id is u64); handled via raw broadcast above.
Err(_) => continue,
};
if let Some(id) = parsed.id {
// Response to a command
let mut pending = pending_clone.lock().await;
if let Some(tx) = pending.remove(&id) {
let _ = tx.send(parsed);
}
} else if let Some(ref method) = parsed.method {
// Event
let event = CdpEvent {
method: method.clone(),
params: parsed.params.clone().unwrap_or(Value::Null),
session_id: parsed.session_id.clone(),
};
let _ = event_tx_clone.send(event);
}
}
// Reader loop exited (connection closed or error). Drop all pending
// command senders so callers get an immediate channel-closed error
// instead of waiting for the 30-second timeout.
pending_clone.lock().await.clear();
// Stop the keepalive task — the connection is gone.
let _ = cancel_tx.send(true);
});
// Spawn a keepalive task that sends WebSocket Ping frames at a regular
// interval. This prevents intermediate proxies (Envoy, nginx, OpenResty,
// cloud load balancers) from closing idle WebSocket connections. If the
// send fails, the connection is dead and we stop pinging.
let keepalive_tx = ws_tx.clone();
let keepalive_handle = tokio::spawn(async move {
let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
loop {
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = cancel_rx.changed() => break,
}
let mut tx = keepalive_tx.lock().await;
if tx.send(Message::Ping(Vec::new())).await.is_err() {
break;
}
}
});
Ok(Self {
ws_tx,
next_id: AtomicU64::new(1),
pending,
event_tx,
raw_tx,
_reader_handle: reader_handle,
_keepalive_handle: keepalive_handle,
})
}
pub async fn send_command(
&self,
method: &str,
params: Option<Value>,
session_id: Option<&str>,
) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let cmd = CdpCommand {
id,
method: method.to_string(),
params,
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
};
let json = serde_json::to_string(&cmd)
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
let (tx, rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(id, tx);
}
{
let mut ws_tx = self.ws_tx.lock().await;
ws_tx
.send(Message::Text(json))
.await
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
}
let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
Err(_) => {
self.pending.lock().await.remove(&id);
return Err(format!("CDP command timed out: {}", method));
}
};
if let Some(error) = response.error {
return Err(format!("CDP error ({}): {}", method, error));
}
Ok(response.result.unwrap_or(Value::Null))
}
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
self.event_tx.subscribe()
}
/// Subscribe to all raw incoming CDP messages (responses + events).
/// Used by the inspect proxy to forward traffic to the DevTools frontend.
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
self.raw_tx.subscribe()
}
/// Create a lightweight handle for the inspect WebSocket proxy.
/// Contains only what's needed to forward messages bidirectionally.
pub fn inspect_handle(&self) -> InspectProxyHandle {
InspectProxyHandle {
ws_tx: self.ws_tx.clone(),
raw_tx: self.raw_tx.clone(),
}
}
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
&self,
method: &str,
params: &P,
session_id: Option<&str>,
) -> Result<R, String> {
let params_value = serde_json::to_value(params)
.map_err(|e| format!("Failed to serialize params: {}", e))?;
let result = self
.send_command(method, Some(params_value), session_id)
.await?;
serde_json::from_value(result)
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
}
pub async fn send_command_no_params(
&self,
method: &str,
session_id: Option<&str>,
) -> Result<Value, String> {
self.send_command(method, None, session_id).await
}
/// Send raw JSON through the WebSocket without tracking a response.
/// Used by the inspect proxy to forward DevTools frontend messages.
pub async fn send_raw(&self, json: String) -> Result<(), String> {
let mut ws_tx = self.ws_tx.lock().await;
ws_tx
.send(Message::Text(json))
.await
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
}
}
type WsTx = Arc<
Mutex<
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>,
>,
>;
/// Lightweight handle for the inspect WebSocket proxy, holding only
/// the cloneable parts of CdpClient needed for bidirectional message forwarding.
pub struct InspectProxyHandle {
ws_tx: WsTx,
raw_tx: broadcast::Sender<RawCdpMessage>,
}
impl InspectProxyHandle {
pub async fn send_raw(&self, json: String) -> Result<(), String> {
let mut ws_tx = self.ws_tx.lock().await;
ws_tx
.send(Message::Text(json))
.await
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
}
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
self.raw_tx.subscribe()
}
}
/// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection.
/// This is best-effort: failures are silently ignored since the WebSocket-level
/// Ping keepalive provides the primary connection liveness mechanism.
fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>) {
let tcp_stream = match stream {
tokio_tungstenite::MaybeTlsStream::Plain(s) => s,
tokio_tungstenite::MaybeTlsStream::Rustls(s) => s.get_ref().0,
_ => return,
};
// SockRef borrows the fd without taking ownership.
let sock = socket2::SockRef::from(tcp_stream);
let keepalive = socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(30));
// with_interval sets TCP_KEEPINTVL — the time between probes after the
// first keepalive probe goes unanswered. Available on most platforms
// (Linux, macOS, Windows, FreeBSD, etc.) but not OpenBSD or Haiku.
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
let keepalive = keepalive.with_interval(std::time::Duration::from_secs(10));
let _ = sock.set_tcp_keepalive(&keepalive);
}
+387
View File
@@ -0,0 +1,387 @@
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
use super::types::BrowserVersionInfo;
/// Default timeout for CDP discovery HTTP requests.
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
/// Discover the CDP WebSocket URL for the given host and port.
///
/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
/// WebSocket connection to `/devtools/browser`. The returned URL has its
/// host/port rewritten to match the requested target.
///
/// An optional `query` string (without the leading `?`) is appended to the
/// final WebSocket URL so that user-supplied URL parameters (e.g.
/// `?mode=Hello`) are forwarded to the remote endpoint.
pub async fn discover_cdp_url(
host: &str,
port: u16,
query: Option<&str>,
) -> Result<String, String> {
discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
}
/// Like [`discover_cdp_url`] but with a custom request timeout.
pub async fn discover_cdp_url_with_timeout(
host: &str,
port: u16,
query: Option<&str>,
timeout: Duration,
) -> Result<String, String> {
// Primary: /json/version (standard path)
let version_err = match fetch_cdp_info(host, port, timeout).await {
Ok(info) => {
if let Some(ws_url) = info.web_socket_debugger_url {
return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
}
format!(
"No webSocketDebuggerUrl in /json/version at {}:{}",
host, port
)
}
Err(e) => e,
};
// Fallback: /json/list (returns target list; look for the browser target)
let list_err = match fetch_cdp_list(host, port, timeout).await {
Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
Err(e) => e,
};
// Final fallback: direct WebSocket at /devtools/browser.
// Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
// CDP over WebSocket but does not serve HTTP discovery endpoints.
match discover_cdp_ws(host, port, timeout).await {
Ok(ws_url) => Ok(append_query(&ws_url, query)),
Err(ws_err) => Err(format!(
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
host, port, version_err, list_err, ws_err
)),
}
}
/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
fn bracket_ipv6(host: &str) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{}]", host)
} else {
host.to_string()
}
}
/// Fetch `/json/version` from the given host:port and parse the response.
async fn fetch_cdp_info(
host: &str,
port: u16,
timeout: Duration,
) -> Result<BrowserVersionInfo, String> {
let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
.await
.map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
.map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
}
/// Rewrite the host and port in a WebSocket URL to match the target we
/// actually connected to. Chrome's `/json/version` always returns
/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
/// browser is on a remote machine or behind a port-forward.
fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
if let Ok(mut parsed) = url::Url::parse(ws_url) {
let _ = parsed.set_host(Some(&bracket_ipv6(host)));
let _ = parsed.set_port(Some(port));
parsed.to_string()
} else {
ws_url.to_string()
}
}
/// Append a query string to a URL, preserving any existing query parameters.
fn append_query(url: &str, query: Option<&str>) -> String {
match query {
Some(q) if !q.is_empty() => {
if let Ok(mut parsed) = url::Url::parse(url) {
{
let mut pairs = parsed.query_pairs_mut();
pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
}
parsed.to_string()
} else {
// Fallback: raw string append
if url.contains('?') {
format!("{}&{}", url, q)
} else {
format!("{}?{}", url, q)
}
}
}
_ => url.to_string(),
}
}
/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
/// target with `type == "browser"`, or the first target if none has that type.
async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
.await
.map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
.map_err(|e| {
format!(
"Failed to connect to /json/list at {}:{}: {}",
host, port, e
)
})?;
let targets: Vec<serde_json::Value> =
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
// Prefer targets with type "browser", fall back to first target with a ws URL
let browser_target = targets
.iter()
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
let target = browser_target.or_else(|| targets.first());
target
.and_then(|t| t.get("webSocketDebuggerUrl"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
}
/// Discover a CDP endpoint by connecting directly to `ws://host:port/devtools/browser`
/// and verifying it responds to `Browser.getVersion`.
/// Returns the WebSocket URL on success.
async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
tokio::time::timeout(timeout, async {
let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url)
.await
.map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
ws_stream
.send(Message::Text(cmd.into()))
.await
.map_err(|e| format!("Failed to send command: {}", e))?;
#[derive(serde::Deserialize)]
struct CdpReply {
id: u64,
}
let mut result: Result<(), String> = Err("No valid CDP response received".to_string());
while let Some(msg) = ws_stream.next().await {
match msg {
Ok(Message::Text(text)) => {
if serde_json::from_str::<CdpReply>(&text).is_ok_and(|r| r.id == 1) {
result = Ok(());
break;
}
}
Ok(Message::Close(_)) | Err(_) => break,
_ => continue,
}
}
let _ = ws_stream.close(None).await;
result
})
.await
.map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
.map(|()| ws_url)
}
async fn reqwest_get_string(url: &str) -> Result<String, String> {
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
resp.text().await.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const HTTP_404: &str =
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
fn http_200(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
body.len(), body
)
}
async fn accept_http(listener: &TcpListener, response: &str) {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await;
s.write_all(response.as_bytes()).await.unwrap();
}
#[tokio::test]
async fn discovers_ws_url_from_json_version() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
accept_http(
&listener,
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
)
.await;
});
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
server.await.unwrap();
}
#[tokio::test]
async fn returns_error_when_version_returns_invalid_json() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
accept_http(&listener, &http_200("not-json")).await;
// /json/list and ws fallback both fail (server closes)
});
let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
assert!(err.contains("Invalid /json/version response"));
server.await.unwrap();
}
#[tokio::test]
async fn falls_back_to_json_list_on_version_404() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
accept_http(&listener, HTTP_404).await;
accept_http(
&listener,
&http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
).await;
});
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
assert!(ws_url.contains("/devtools/browser/abc"));
assert!(ws_url.contains(&port.to_string()));
server.await.unwrap();
}
#[tokio::test]
async fn falls_back_to_ws_when_http_returns_404() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
// /json/version -> 404, /json/list -> 404
accept_http(&listener, HTTP_404).await;
accept_http(&listener, HTTP_404).await;
// WebSocket handshake + respond to Browser.getVersion
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
if let Some(Ok(Message::Text(text))) = ws.next().await {
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
let id = req.get("id").unwrap();
let reply = format!(
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/136"}}}}"#,
id
);
ws.send(Message::Text(reply)).await.unwrap();
}
let _ = ws.close(None).await;
});
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/devtools/browser", port));
server.await.unwrap();
}
#[test]
fn rewrite_ws_host_replaces_host_and_port() {
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
}
#[test]
fn rewrite_ws_host_handles_ipv6() {
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
let rewritten = rewrite_ws_host(original, "::1", 9222);
assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
}
#[test]
fn append_query_adds_params_to_url_without_query() {
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
let result = append_query(url, Some("mode=Hello"));
assert_eq!(
result,
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
);
}
#[test]
fn append_query_merges_with_existing_query() {
let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
let result = append_query(url, Some("mode=Hello"));
assert_eq!(
result,
"ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
);
}
#[test]
fn append_query_noop_for_none() {
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
let result = append_query(url, None);
assert_eq!(result, url);
}
#[test]
fn append_query_noop_for_empty() {
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
let result = append_query(url, Some(""));
assert_eq!(result, url);
}
#[test]
fn append_query_handles_multiple_params() {
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
let result = append_query(url, Some("mode=Hello&token=abc"));
assert_eq!(
result,
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
);
}
#[tokio::test]
async fn discover_preserves_query_params() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
accept_http(
&listener,
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
)
.await;
});
let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
.await
.unwrap();
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
server.await.unwrap();
}
}
+495
View File
@@ -0,0 +1,495 @@
use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::discovery::discover_cdp_url_with_timeout;
const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; // 1 week, the documented maximum
const MAX_LOG_LINES: usize = 40;
pub struct LightpandaProcess {
child: Child,
pub ws_url: String,
_log_drainers: Vec<std::thread::JoinHandle<()>>,
}
impl LightpandaProcess {
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for LightpandaProcess {
fn drop(&mut self) {
self.kill();
}
}
#[derive(Default)]
pub struct LightpandaLaunchOptions {
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub port: Option<u16>,
}
fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
port.to_string(),
"--timeout".to_string(),
LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(),
];
if let Some(proxy) = proxy {
args.push("--http_proxy".to_string());
args.push(proxy.to_string());
}
args
}
#[derive(Clone, Default)]
struct LaunchLogBuffer {
stdout: Arc<Mutex<VecDeque<String>>>,
stderr: Arc<Mutex<VecDeque<String>>>,
}
impl LaunchLogBuffer {
fn push_stdout(&self, line: String) {
push_bounded(&self.stdout, line);
}
fn push_stderr(&self, line: String) {
push_bounded(&self.stderr, line);
}
fn snapshot_stdout(&self) -> Vec<String> {
self.stdout
.lock()
.expect("stdout log buffer poisoned")
.iter()
.cloned()
.collect()
}
fn snapshot_stderr(&self) -> Vec<String> {
self.stderr
.lock()
.expect("stderr log buffer poisoned")
.iter()
.cloned()
.collect()
}
}
fn push_bounded(buffer: &Mutex<VecDeque<String>>, line: String) {
let mut guard = buffer.lock().expect("log buffer poisoned");
if guard.len() >= MAX_LOG_LINES {
guard.pop_front();
}
guard.push_back(line);
}
pub fn find_lightpanda() -> Option<PathBuf> {
#[cfg(unix)]
{
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
if let Some(home) = dirs::home_dir() {
let candidates = [
home.join(".lightpanda/lightpanda"),
home.join(".local/bin/lightpanda"),
];
for c in &candidates {
if c.exists() {
return Some(c.clone());
}
}
}
None
}
pub async fn launch_lightpanda(
options: &LightpandaLaunchOptions,
) -> Result<LightpandaProcess, String> {
let binary_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => find_lightpanda().ok_or(
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
)?,
};
let port = match options.port {
Some(p) => p,
None => TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
};
let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
let mut child = Command::new(&binary_path)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
let ws_url =
match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
.await
{
Ok(url) => url,
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
};
Ok(LightpandaProcess {
child,
ws_url,
_log_drainers: log_drainers,
})
}
fn start_log_drainers(
child: &mut Child,
) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
let stdout = child.stdout.take().ok_or_else(|| {
let _ = child.kill();
"Failed to capture Lightpanda stdout".to_string()
})?;
let stderr = child.stderr.take().ok_or_else(|| {
let _ = child.kill();
"Failed to capture Lightpanda stderr".to_string()
})?;
let logs = LaunchLogBuffer::default();
let stdout_logs = logs.clone();
let stderr_logs = logs.clone();
let stdout_handle =
std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line)));
let stderr_handle =
std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line)));
Ok((logs, vec![stdout_handle, stderr_handle]))
}
fn drain_reader<R, F>(reader: R, mut push: F)
where
R: std::io::Read,
F: FnMut(String),
{
for line in BufReader::new(reader).lines() {
match line {
Ok(line) => push(line),
Err(_) => break,
}
}
}
async fn wait_for_lightpanda_ready(
child: &mut Child,
port: u16,
logs: &LaunchLogBuffer,
startup_timeout: Duration,
) -> Result<String, String> {
let deadline = std::time::Instant::now() + startup_timeout;
let mut last_probe_error = None;
loop {
if let Ok(Some(status)) = child.try_wait() {
// Give the drainer threads a brief window to flush the last log lines
// before we snapshot them. This is best-effort: lines written just
// before exit may still be missing, but the most useful output (early
// startup errors) will already be in the buffer.
tokio::time::sleep(Duration::from_millis(25)).await;
return Err(lightpanda_launch_error(
&format!(
"Lightpanda exited before CDP became ready (status: {})",
status
),
logs,
last_probe_error.as_deref(),
));
}
match discover_cdp_url_with_timeout("127.0.0.1", port, None, LIGHTPANDA_DISCOVERY_TIMEOUT)
.await
{
Ok(ws_url) => return Ok(ws_url),
Err(err) => last_probe_error = Some(err),
}
if std::time::Instant::now() >= deadline {
return Err(lightpanda_launch_error(
&format!(
"Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}",
startup_timeout.as_millis(),
port
),
logs,
last_probe_error.as_deref(),
));
}
tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await;
}
}
fn lightpanda_launch_error(
message: &str,
logs: &LaunchLogBuffer,
last_probe_error: Option<&str>,
) -> String {
let stdout_lines = logs.snapshot_stdout();
let stderr_lines = logs.snapshot_stderr();
let mut details = Vec::new();
if let Some(err) = last_probe_error {
details.push(format!("Last probe error: {}", err));
}
if !stderr_lines.is_empty() {
details.push(format!(
"Lightpanda stderr (last {} lines):\n {}",
stderr_lines.len(),
stderr_lines.join("\n ")
));
}
if !stdout_lines.is_empty() {
details.push(format!(
"Lightpanda stdout (last {} lines):\n {}",
stdout_lines.len(),
stdout_lines.join("\n ")
));
}
if details.is_empty() {
format!("{} (no stdout/stderr output from Lightpanda)", message)
} else {
format!("{}\n{}", message, details.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener as TokioTcpListener;
fn unused_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn waits_for_ready_without_logs() {
let port = unused_port();
tokio::spawn(serve_json_version_once_after_delay(
port,
150,
r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#,
));
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 5"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
.await
.unwrap();
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
#[tokio::test]
async fn child_exit_surfaces_logs() {
let port = unused_port();
let mut child = Command::new("/bin/sh")
.args(["-c", "echo boom >&2; sleep 0.1; exit 23"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
.await
.unwrap_err();
assert!(err.contains("Lightpanda exited before CDP became ready"));
assert!(err.contains("boom"));
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_reports_last_probe_error() {
let port = unused_port();
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 30"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let timeout = Duration::from_millis(300);
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
let err = tokio::time::timeout(
Duration::from_secs(2),
wait_for_lightpanda_ready(&mut child, port, &logs, timeout),
)
.await
.expect("ready wait should return before outer timeout")
.unwrap_err();
assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint"));
assert!(
err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP")
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn test_find_lightpanda_returns_none_when_missing() {
let _ = find_lightpanda();
}
#[test]
fn test_lightpanda_launch_error_no_logs() {
let logs = LaunchLogBuffer::default();
let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
assert!(msg.contains("no stdout/stderr output"));
}
#[test]
fn test_lightpanda_launch_error_with_lines() {
let logs = LaunchLogBuffer::default();
logs.push_stdout("stdout line".to_string());
logs.push_stderr("stderr line".to_string());
let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
assert!(msg.contains("stdout line"));
assert!(msg.contains("stderr line"));
assert!(msg.contains("Last probe error: connect failed"));
}
#[test]
fn test_default_options() {
let opts = LightpandaLaunchOptions::default();
assert!(opts.executable_path.is_none());
assert!(opts.proxy.is_none());
assert!(opts.port.is_none());
}
#[test]
fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() {
let args = build_lightpanda_serve_args(9222, None);
assert_eq!(
args,
vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
"9222".to_string(),
"--timeout".to_string(),
"604800".to_string(),
]
);
}
#[test]
fn test_build_lightpanda_serve_args_with_proxy() {
let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080"));
assert_eq!(
args,
vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
"9333".to_string(),
"--timeout".to_string(),
"604800".to_string(),
"--http_proxy".to_string(),
"http://127.0.0.1:8080".to_string(),
]
);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod chrome;
pub mod client;
pub mod discovery;
pub mod lightpanda;
pub mod types;
+586
View File
@@ -0,0 +1,586 @@
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
/// Deserialize a value that may be either a string or an integer into a String.
/// Lightpanda sends numeric nodeIds/childIds in AX tree responses, while Chrome
/// sends strings. This accepts both.
fn string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match v {
Value::String(s) => Ok(s),
Value::Number(n) => Ok(n.to_string()),
other => Err(serde::de::Error::custom(format!(
"expected string or integer, got {}",
other
))),
}
}
/// Deserialize an optional Vec where each element may be a string or integer.
fn opt_vec_string_or_int<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<Vec<Value>> = Option::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(vec) => {
let mut result = Vec::with_capacity(vec.len());
for v in vec {
match v {
Value::String(s) => result.push(s),
Value::Number(n) => result.push(n.to_string()),
other => {
return Err(serde::de::Error::custom(format!(
"expected string or integer in array, got {}",
other
)))
}
}
}
Ok(Some(result))
}
}
}
// ---------------------------------------------------------------------------
// CDP message envelope
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CdpCommand {
pub id: u64,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CdpMessage {
pub id: Option<u64>,
pub result: Option<Value>,
pub error: Option<CdpError>,
pub method: Option<String>,
pub params: Option<Value>,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CdpError {
pub code: Option<i64>,
pub message: String,
pub data: Option<String>,
}
impl std::fmt::Display for CdpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
// ---------------------------------------------------------------------------
// CDP events (broadcast to subscribers)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct CdpEvent {
pub method: String,
pub params: Value,
pub session_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Target domain
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInfo {
pub target_id: String,
#[serde(rename = "type")]
pub target_type: String,
pub title: String,
pub url: String,
pub attached: Option<bool>,
pub browser_context_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTargetsResult {
pub target_infos: Vec<TargetInfo>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachToTargetParams {
pub target_id: String,
pub flatten: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachToTargetResult {
pub session_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDiscoverTargetsParams {
pub discover: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTargetParams {
pub url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTargetResult {
pub target_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseTargetParams {
pub target_id: String,
}
// Target events
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetCreatedEvent {
pub target_info: TargetInfo,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetDestroyedEvent {
pub target_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInfoChangedEvent {
pub target_info: TargetInfo,
}
// ---------------------------------------------------------------------------
// Page domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageNavigateParams {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub referrer: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageNavigateResult {
pub frame_id: String,
pub loader_id: Option<String>,
pub error_text: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameNavigatedEvent {
pub frame: FrameInfo,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameInfo {
pub id: String,
pub url: String,
pub parent_id: Option<String>,
pub name: Option<String>,
}
// Page.javascriptDialogOpening
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JavascriptDialogOpeningEvent {
pub url: String,
pub message: String,
#[serde(rename = "type")]
pub dialog_type: String,
pub default_prompt: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HandleJavaScriptDialogParams {
pub accept: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_text: Option<String>,
}
// ---------------------------------------------------------------------------
// Runtime domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluateParams {
pub expression: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_by_value: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub await_promise: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluateResult {
pub result: RemoteObject,
pub exception_details: Option<ExceptionDetails>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteObject {
#[serde(rename = "type")]
pub object_type: String,
pub subtype: Option<String>,
pub value: Option<Value>,
pub description: Option<String>,
pub object_id: Option<String>,
pub class_name: Option<String>,
pub unserializable_value: Option<String>,
pub preview: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExceptionDetails {
pub text: String,
pub exception: Option<RemoteObject>,
pub line_number: Option<i64>,
pub column_number: Option<i64>,
}
// Runtime.consoleAPICalled
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsoleApiCalledEvent {
#[serde(rename = "type")]
pub call_type: String,
pub args: Vec<RemoteObject>,
pub timestamp: Option<f64>,
}
// Runtime.exceptionThrown
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExceptionThrownEvent {
pub timestamp: f64,
pub exception_details: ExceptionDetails,
}
// ---------------------------------------------------------------------------
// Accessibility domain
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetFullAXTreeResult {
pub nodes: Vec<AXNode>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AXNode {
#[serde(deserialize_with = "string_or_int")]
pub node_id: String,
pub role: Option<AXValue>,
pub name: Option<AXValue>,
pub value: Option<AXValue>,
pub description: Option<AXValue>,
pub properties: Option<Vec<AXProperty>>,
#[serde(default, deserialize_with = "opt_vec_string_or_int")]
pub child_ids: Option<Vec<String>>,
pub backend_d_o_m_node_id: Option<i64>,
pub ignored: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AXValue {
#[serde(rename = "type")]
pub value_type: String,
pub value: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AXProperty {
pub name: String,
pub value: AXValue,
}
// ---------------------------------------------------------------------------
// Network domain (minimal for Phase 1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestWillBeSentEvent {
pub request_id: String,
pub request: NetworkRequest,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkRequest {
pub url: String,
pub method: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadingFinishedEvent {
pub request_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadingFailedEvent {
pub request_id: String,
}
// ---------------------------------------------------------------------------
// DOM domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomResolveNodeParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_group: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomResolveNodeResult {
pub object: RemoteObject,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetBoxModelParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetBoxModelResult {
pub model: BoxModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoxModel {
pub content: Vec<f64>,
pub padding: Vec<f64>,
pub border: Vec<f64>,
pub margin: Vec<f64>,
pub width: i64,
pub height: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomQuerySelectorParams {
pub node_id: i64,
pub selector: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomQuerySelectorResult {
pub node_id: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetDocumentParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetDocumentResult {
pub root: DomNode,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomNode {
pub node_id: i64,
pub backend_node_id: Option<i64>,
pub node_type: Option<i64>,
pub node_name: Option<String>,
pub children: Option<Vec<DomNode>>,
}
// ---------------------------------------------------------------------------
// Input domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DispatchMouseEventParams {
#[serde(rename = "type")]
pub event_type: String,
pub x: f64,
pub y: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub button: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buttons: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub click_count: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_x: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_y: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modifiers: Option<i32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DispatchKeyEventParams {
#[serde(rename = "type")]
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unmodified_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub windows_virtual_key_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_virtual_key_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modifiers: Option<i32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InsertTextParams {
pub text: String,
}
// ---------------------------------------------------------------------------
// Page.captureScreenshot
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureScreenshotParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clip: Option<Viewport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_surface: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capture_beyond_viewport: Option<bool>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Viewport {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub scale: f64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureScreenshotResult {
pub data: String,
}
// ---------------------------------------------------------------------------
// Runtime.callFunctionOn
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallFunctionOnParams {
pub function_declaration: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<CallArgument>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_by_value: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub await_promise: Option<bool>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallArgument {
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Version info (from /json/version)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserVersionInfo {
#[serde(rename = "webSocketDebuggerUrl")]
pub web_socket_debugger_url: Option<String>,
#[serde(rename = "Browser")]
pub browser: Option<String>,
}
/// Auto-generated CDP types from protocol JSON files in `cdp-protocol/`.
///
/// To populate: download `browser_protocol.json` and `js_protocol.json` from
/// <https://github.com/nicolo-ribaudo/nicolo-ribaudo.github.io/> (or any
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
///
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
#[allow(clippy::upper_case_acronyms)]
pub mod generated {
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
}
+100
View File
@@ -0,0 +1,100 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::cdp::client::CdpClient;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
#[serde(default)]
pub expires: f64,
#[serde(default)]
pub size: i64,
#[serde(default)]
pub http_only: bool,
#[serde(default)]
pub secure: bool,
#[serde(default)]
pub session: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
}
pub async fn get_all_cookies(client: &CdpClient, session_id: &str) -> Result<Vec<Cookie>, String> {
let result = client
.send_command_no_params("Network.getAllCookies", Some(session_id))
.await?;
let cookies: Vec<Cookie> = result
.get("cookies")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
Ok(cookies)
}
pub async fn get_cookies(
client: &CdpClient,
session_id: &str,
urls: Option<Vec<String>>,
) -> Result<Vec<Cookie>, String> {
let params = match urls {
Some(ref u) if !u.is_empty() => json!({ "urls": u }),
_ => json!({}),
};
let result = client
.send_command("Network.getCookies", Some(params), Some(session_id))
.await?;
let cookies: Vec<Cookie> = result
.get("cookies")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
Ok(cookies)
}
pub async fn set_cookies(
client: &CdpClient,
session_id: &str,
cookies: Vec<Value>,
current_url: Option<&str>,
) -> Result<(), String> {
let cookies: Vec<Value> = cookies
.into_iter()
.map(|mut c| {
// Auto-fill url if no domain/path/url provided
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
c.as_object_mut().map(|m| {
m.insert(
"url".to_string(),
Value::String(current_url.unwrap().to_string()),
)
});
}
c
})
.collect();
client
.send_command(
"Network.setCookies",
Some(json!({ "cookies": cookies })),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn clear_cookies(client: &CdpClient, session_id: &str) -> Result<(), String> {
client
.send_command_no_params("Network.clearBrowserCookies", Some(session_id))
.await?;
Ok(())
}
+575
View File
@@ -0,0 +1,575 @@
use serde_json::Value;
use std::env;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::signal;
use tokio::sync::{mpsc, RwLock};
use super::actions::{execute_command, DaemonState};
use super::cdp::client::CdpClient;
use super::state;
use super::stream::StreamServer;
pub async fn run_daemon(session: &str) {
let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
}
// When debug mode is on, redirect stderr to a log file so daemon
// output can be inspected (the daemon normally has stderr piped to its
// parent which drops the read end after startup).
#[cfg(unix)]
if env::var("AGENT_BROWSER_DEBUG").is_ok() {
let log_path = socket_dir.join(format!("{}.log", session));
if let Ok(file) = fs::File::create(&log_path) {
use std::os::unix::io::IntoRawFd;
let fd = file.into_raw_fd();
unsafe {
libc::dup2(fd, 2);
libc::close(fd);
}
let _ = writeln!(
std::io::stderr(),
"[daemon] Debug logging started for session: {}",
session
);
}
}
let pid_path = socket_dir.join(format!("{}.pid", session));
let _ = fs::write(&pid_path, process::id().to_string());
// On Unix the daemon listens on a Unix domain socket; on Windows it uses
// TCP, so there is no .sock file — only a .port file written by the server.
let socket_path = socket_dir.join(format!("{}.sock", session));
#[cfg(unix)]
if socket_path.exists() {
let _ = fs::remove_file(&socket_path);
}
#[cfg(windows)]
{
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
}
let stream_path = socket_dir.join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
if let Ok(days) = days_str.parse::<u64>() {
if days > 0 {
let _ = state::state_clean(days);
}
}
}
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
match StreamServer::start_without_client(preferred_port, session.to_string(), true).await {
Ok((stream_server, client_slot)) => {
stream_client = Some(client_slot.clone());
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
}
stream_server_instance = Some(Arc::new(stream_server));
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
}
}
// Auto-shutdown the daemon after this many ms of inactivity (no commands received).
// Disabled when unset or 0.
let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&ms| ms > 0);
let result = run_socket_server(
&socket_path,
session,
stream_client,
stream_server_instance,
idle_timeout_ms,
)
.await;
#[cfg(unix)]
{
let _ = fs::remove_file(&socket_path);
}
#[cfg(windows)]
{
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
}
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&stream_path);
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
if let Err(e) = result {
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
process::exit(1);
}
}
#[cfg(unix)]
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
stream_server: Option<Arc<StreamServer>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::UnixListener;
let listener =
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
let stream_file: Option<PathBuf> = if stream_server.is_some() {
let dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
Some(dir.join(format!("{}.stream", session)))
} else {
None
};
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
);
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
let mut sleep_pin = sleep_future.map(Box::pin);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let reset_tx = reset_tx.clone();
let sf = stream_file.clone();
tokio::spawn(async move {
handle_connection(stream, state, reset_tx, sf).await;
});
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
}
}
}
_ = drain_interval.tick() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
if mgr.has_process_exited() {
let _ = mgr.close().await;
s.browser = None;
s.screencasting = false;
s.update_stream_client().await;
} else {
s.drain_cdp_events_background().await;
}
}
}
_ = async {
if let Some(ref mut s) = sleep_pin {
s.as_mut().await
} else {
std::future::pending::<()>().await
}
}, if idle_timeout_ms.is_some() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
break;
}
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
continue;
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
break;
}
}
}
Ok(())
}
#[cfg(windows)]
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
stream_server: Option<Arc<StreamServer>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::TcpListener;
let preferred_port = get_port_for_session(session);
// Try the hash-derived port first; if it is blocked (e.g. Windows Hyper-V
// excluded port range), fall back to an OS-assigned ephemeral port.
let listener = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await {
Ok(l) => l,
Err(_) => TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind TCP: {}", e))?,
};
let actual_port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {}", e))?
.port();
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
let port_path = socket_dir.join(format!("{}.port", session));
let _ = fs::write(&port_path, actual_port.to_string());
let stream_file: Option<PathBuf> = if stream_server.is_some() {
Some(socket_dir.join(format!("{}.stream", session)))
} else {
None
};
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
);
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
loop {
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
let mut sleep_pin = sleep_future.map(Box::pin);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let reset_tx = reset_tx.clone();
let sf = stream_file.clone();
tokio::spawn(async move {
handle_connection(stream, state, reset_tx, sf).await;
});
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
}
}
}
_ = async {
if let Some(ref mut s) = sleep_pin {
s.as_mut().await
} else {
std::future::pending::<()>().await
}
}, if idle_timeout_ms.is_some() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
let _ = fs::remove_file(&port_path);
break;
}
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
continue;
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
let _ = fs::remove_file(&port_path);
break;
}
}
}
Ok(())
}
async fn handle_connection<S>(
stream: S,
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
stream_file_cleanup: Option<PathBuf>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match buf_reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if looks_like_http(trimmed) {
break;
}
let cmd: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
let err = serde_json::json!({
"success": false,
"error": format!("Invalid JSON: {}", e),
});
let mut resp = serde_json::to_string(&err).unwrap_or_default();
resp.push('\n');
let _ = writer.write_all(resp.as_bytes()).await;
continue;
}
};
if let Some(ref tx) = idle_reset_tx {
let _ = tx.try_send(());
}
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let response = {
let mut s = state.lock().await;
execute_command(&cmd, &mut s).await
};
let mut resp = serde_json::to_string(&response).unwrap_or_default();
resp.push('\n');
if writer.write_all(resp.as_bytes()).await.is_err() {
break;
}
if is_close {
if let Some(ref path) = stream_file_cleanup {
let _ = fs::remove_file(path);
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
process::exit(0);
}
}
Err(_) => break,
}
}
}
fn looks_like_http(line: &str) -> bool {
let prefixes = [
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ",
];
prefixes.iter().any(|p| line.starts_with(p))
}
async fn shutdown_signal() {
#[cfg(unix)]
{
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
let _ = writeln!(std::io::stderr(), "Failed to install SIGINT handler: {}", e);
process::exit(1);
}
};
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
let _ = writeln!(
std::io::stderr(),
"Failed to install SIGTERM handler: {}",
e
);
process::exit(1);
}
};
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
Ok(s) => s,
Err(e) => {
let _ = writeln!(std::io::stderr(), "Failed to install SIGHUP handler: {}", e);
process::exit(1);
}
};
tokio::select! {
_ = sigint.recv() => {}
_ = sigterm.recv() => {}
_ = sighup.recv() => {}
}
}
#[cfg(windows)]
{
if let Err(e) = signal::ctrl_c().await {
let _ = writeln!(std::io::stderr(), "Failed to install Ctrl+C handler: {}", e);
process::exit(1);
}
}
}
fn get_daemon_socket_dir() -> PathBuf {
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("agent-browser");
}
}
if let Some(home) = dirs::home_dir() {
return home.join(".agent-browser");
}
std::env::temp_dir().join("agent-browser")
}
#[cfg(windows)]
fn get_port_for_session(session: &str) -> u16 {
let mut hash: i32 = 0;
for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
}
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[cfg(windows)]
#[test]
fn test_port_matches_client_algorithm() {
assert_eq!(get_port_for_session("default"), 50838);
assert_eq!(get_port_for_session("my-session"), 63105);
assert_eq!(get_port_for_session("work"), 51184);
assert_eq!(get_port_for_session(""), 49152);
}
/// Guard against re-introducing `waitpid(-1)` in daemon code.
///
/// Issue #1035: a SIGCHLD handler that called `waitpid(-1, WNOHANG)` was
/// added in v0.22.3 to reap zombie Chrome processes. This races with
/// Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)`
/// reaps *any* child, stealing the exit status before Rust can collect
/// it. The result is ECHILD errors in `BrowserManager::has_process_exited()`
/// and `ChromeProcess::kill()`, which can leave the daemon in a broken
/// state or cause hangs on certain Linux configurations.
///
/// The fix uses the existing 500ms drain interval to call
/// `has_process_exited()` (which delegates to `Child::try_wait()`)
/// for targeted, race-free zombie detection.
#[test]
fn test_no_waitpid_minus_one_in_daemon() {
let source = include_str!("daemon.rs");
// Only check production code (everything before `#[cfg(test)]`)
let production_code = source.split("#[cfg(test)]").next().unwrap_or(source);
assert!(
!production_code.contains("waitpid(-1"),
"daemon.rs production code must not call waitpid(-1, ...). \
Use Child::try_wait() via has_process_exited() instead. \
See issue #1035."
);
}
/// Verify that `Child::try_wait()` correctly detects a crashed child
/// without needing a global SIGCHLD handler or `waitpid(-1)`.
/// This is what `has_process_exited()` uses in the fixed code.
#[cfg(unix)]
#[test]
fn test_child_try_wait_detects_exit_without_sigchld_handler() {
use std::process::{Command, Stdio};
let mut child = Command::new("/bin/sh")
.args(["-c", "exit 42"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn child");
std::thread::sleep(std::time::Duration::from_millis(200));
match child.try_wait() {
Ok(Some(status)) => {
assert!(
!status.success(),
"child exited with code 42, should not be success"
);
}
Ok(None) => panic!("try_wait() returned None but child should have exited"),
Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e),
}
}
/// Verify that `ChromeProcess::has_exited()` (which uses `Child::try_wait()`)
/// correctly detects a killed child, the same way the drain interval does
/// in the fixed daemon code. This ensures crash detection works without
/// a SIGCHLD handler.
#[cfg(unix)]
#[test]
fn test_has_exited_detects_killed_process() {
use std::process::{Command, Stdio};
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 60"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn child");
// Process should be running
match child.try_wait() {
Ok(None) => {} // expected
other => panic!("expected Ok(None) for running process, got {:?}", other),
}
// Kill it (simulates Chrome crash)
child.kill().expect("failed to kill child");
std::thread::sleep(std::time::Duration::from_millis(100));
// try_wait should detect the exit
match child.try_wait() {
Ok(Some(_)) => {} // expected: detected the crash
other => panic!(
"expected Ok(Some(_)) after kill, got {:?}. \
Crash detection via try_wait() must work for the drain \
interval fix (issue #1035) to function correctly.",
other
),
}
}
}
+274
View File
@@ -0,0 +1,274 @@
use serde_json::{json, Value};
use similar::{ChangeTag, TextDiff};
pub struct ScreenshotDiffResult {
pub total_pixels: u64,
pub different_pixels: u64,
pub mismatch_percentage: f64,
pub matched: bool,
pub diff_image: Option<Vec<u8>>,
pub dimension_mismatch: Option<Value>,
}
pub struct SnapshotDiffResult {
pub diff: String,
pub additions: usize,
pub removals: usize,
pub unchanged: usize,
pub changed: bool,
}
pub fn diff_screenshot(
baseline: &[u8],
current: &[u8],
threshold: f64,
) -> Result<ScreenshotDiffResult, String> {
let img_a = image::load_from_memory(baseline)
.map_err(|e| format!("Failed to decode baseline image: {}", e))?;
let img_b = image::load_from_memory(current)
.map_err(|e| format!("Failed to decode current image: {}", e))?;
let (wa, ha) = (img_a.width(), img_a.height());
let (wb, hb) = (img_b.width(), img_b.height());
if wa != wb || ha != hb {
return Ok(ScreenshotDiffResult {
total_pixels: (wa as u64) * (ha as u64),
different_pixels: (wa as u64) * (ha as u64),
mismatch_percentage: 100.0,
matched: false,
diff_image: None,
dimension_mismatch: Some(json!({
"expected": { "width": wa, "height": ha },
"actual": { "width": wb, "height": hb },
})),
});
}
let rgba_a = img_a.to_rgba8();
let rgba_b = img_b.to_rgba8();
let total = (wa as u64) * (ha as u64);
let max_color_distance = threshold * 255.0 * (3.0_f64).sqrt();
let mut different = 0u64;
let mut diff_img = image::RgbaImage::new(wa, ha);
for y in 0..ha {
for x in 0..wa {
let pa = rgba_a.get_pixel(x, y);
let pb = rgba_b.get_pixel(x, y);
let dr = (pa[0] as f64) - (pb[0] as f64);
let dg = (pa[1] as f64) - (pb[1] as f64);
let db = (pa[2] as f64) - (pb[2] as f64);
let dist = (dr * dr + dg * dg + db * db).sqrt();
if dist > max_color_distance {
different += 1;
diff_img.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
} else {
let gray = ((pa[0] as u16 + pa[1] as u16 + pa[2] as u16) / 3) as u8;
let dimmed = (gray as f64 * 0.3) as u8;
diff_img.put_pixel(x, y, image::Rgba([dimmed, dimmed, dimmed, 255]));
}
}
}
let mismatch = if total > 0 {
(different as f64 / total as f64) * 100.0
} else {
0.0
};
let diff_bytes = if different > 0 {
let mut buf = std::io::Cursor::new(Vec::new());
diff_img
.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode diff image: {}", e))?;
Some(buf.into_inner())
} else {
None
};
Ok(ScreenshotDiffResult {
total_pixels: total,
different_pixels: different,
mismatch_percentage: mismatch,
matched: different == 0,
diff_image: diff_bytes,
dimension_mismatch: None,
})
}
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
// Fast path: identical inputs.
// This avoids constructing the `similar` TextDiff object and running the diff
// iteration when agents compare a snapshot to itself (common in retry/loop
// workloads).
if before == after {
let unchanged = before.lines().count();
return SnapshotDiffResult {
diff: String::new(),
additions: 0,
removals: 0,
unchanged,
changed: false,
};
}
let text_diff = TextDiff::from_lines(before, after);
let mut additions = 0usize;
let mut removals = 0usize;
let mut unchanged = 0usize;
for change in text_diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => additions += 1,
ChangeTag::Delete => removals += 1,
ChangeTag::Equal => unchanged += 1,
}
}
let changed = additions > 0 || removals > 0;
let diff = text_diff
.unified_diff()
.context_radius(3)
.header("before", "after")
.to_string();
SnapshotDiffResult {
diff,
additions,
removals,
unchanged,
changed,
}
}
/// Legacy JSON diff output for backwards compatibility.
pub fn diff_text(a: &str, b: &str) -> Value {
let result = diff_snapshots(a, b);
json!({
"identical": !result.changed,
"additions": result.additions,
"removals": result.removals,
"deletions": result.removals,
"unchanged": result.unchanged,
"changed": result.changed,
})
}
pub fn diff_unified(a: &str, b: &str) -> String {
diff_snapshots(a, b).diff
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_identical() {
let result = diff_text("hello\nworld", "hello\nworld");
assert_eq!(result.get("identical").unwrap(), true);
assert_eq!(result.get("changed").unwrap(), false);
assert_eq!(result.get("unchanged").unwrap(), 2);
}
#[test]
fn test_diff_additions() {
let result = diff_text("hello\n", "hello\nworld\n");
assert_eq!(result.get("identical").unwrap(), false);
assert_eq!(result.get("changed").unwrap(), true);
assert!(result.get("additions").unwrap().as_i64().unwrap() > 0);
}
#[test]
fn test_diff_deletions() {
let result = diff_text("hello\nworld\n", "hello\n");
assert_eq!(result.get("identical").unwrap(), false);
assert!(result.get("removals").unwrap().as_i64().unwrap() > 0);
}
#[test]
fn test_diff_unified_output() {
let output = diff_unified("a\nb\nc\n", "a\nx\nc\n");
assert!(output.contains("---"));
assert!(output.contains("+++"));
}
#[test]
fn test_snapshot_diff_struct() {
let result = diff_snapshots("line1\nline2\n", "line1\nline3\n");
assert!(result.changed);
assert_eq!(result.additions, 1);
assert_eq!(result.removals, 1);
assert_eq!(result.unchanged, 1);
assert!(!result.diff.is_empty());
}
#[test]
fn test_diff_snapshots_identical_fast_path() {
let input = "hello\nworld\n";
let result = diff_snapshots(input, input);
assert!(!result.changed);
assert_eq!(result.additions, 0);
assert_eq!(result.removals, 0);
assert_eq!(result.unchanged, input.lines().count());
assert!(result.diff.is_empty());
}
#[test]
#[ignore]
fn bench_diff_snapshots_identical_and_changed() {
use std::hint::black_box;
use std::time::Instant;
let identical_a = (0..200)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let identical_b = identical_a.clone();
let changed_a = identical_a.clone();
let changed_b = (0..200)
.map(|i| {
if i == 123 {
format!("line {i} changed")
} else {
format!("line {i}")
}
})
.collect::<Vec<_>>()
.join("\n");
// Keep the iteration count high enough to measure, but low enough
// to avoid long CI times when someone runs `--ignored`.
let iters = 50_000usize;
let start = Instant::now();
let mut acc_changed = 0usize;
for _ in 0..iters {
let r = diff_snapshots(black_box(&identical_a), black_box(&identical_b));
acc_changed ^= r.unchanged;
}
let identical_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let mut acc_changed2 = 0usize;
for _ in 0..iters {
let r = diff_snapshots(black_box(&changed_a), black_box(&changed_b));
acc_changed2 ^= r.additions;
}
let changed_ms = start.elapsed().as_secs_f64() * 1000.0;
// Prevent the compiler from optimizing everything away.
black_box(acc_changed);
black_box(acc_changed2);
println!(
"bench_diff_snapshots_identical_and_changed: iters={iters} identical_ms={identical_ms:.2} changed_ms={changed_ms:.2}"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+362
View File
@@ -0,0 +1,362 @@
use std::io::Write;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use super::cdp::client::InspectProxyHandle;
/// Counter for unique attach IDs so concurrent connections don't collide.
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
///
/// Serves two purposes:
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy
/// CDP messages through the daemon's existing browser-level connection, injecting/stripping
/// `sessionId` so the DevTools frontend sees a page-level view
pub struct InspectServer {
port: u16,
_handle: tokio::task::JoinHandle<()>,
}
impl InspectServer {
/// Start the inspect proxy server.
///
/// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages
/// - `target_id`: the CDP target ID of the page to inspect
/// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222")
pub async fn start(
proxy_handle: InspectProxyHandle,
target_id: String,
chrome_host_port: String,
) -> Result<Self, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind inspect server: {}", e))?;
let port = listener
.local_addr()
.map_err(|e| format!("Failed to get local addr: {}", e))?
.port();
let proxy = Arc::new(proxy_handle);
let handle = tokio::spawn(accept_loop(
listener,
proxy,
target_id,
chrome_host_port,
port,
));
Ok(Self {
port,
_handle: handle,
})
}
pub fn port(&self) -> u16 {
self.port
}
pub fn shutdown(self) {
self._handle.abort();
}
}
async fn accept_loop(
listener: TcpListener,
proxy: Arc<InspectProxyHandle>,
target_id: String,
chrome_host_port: String,
proxy_port: u16,
) {
loop {
let (stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => continue,
};
let proxy = proxy.clone();
let tid = target_id.clone();
let chp = chrome_host_port.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await {
let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e);
}
});
}
}
async fn handle_connection(
stream: tokio::net::TcpStream,
proxy: Arc<InspectProxyHandle>,
target_id: String,
chrome_host_port: String,
proxy_port: u16,
) -> Result<(), String> {
// Peek at the request line to determine routing WITHOUT consuming bytes.
// This is critical: tokio_tungstenite::accept_async needs to read the full
// HTTP upgrade request itself, so we must not consume anything for WS paths.
let mut peek_buf = [0u8; 32];
let n = stream
.peek(&mut peek_buf)
.await
.map_err(|e| e.to_string())?;
let peek = String::from_utf8_lossy(&peek_buf[..n]);
if peek.starts_with("GET /ws") {
return handle_ws_proxy(stream, proxy, target_id).await;
}
if peek.starts_with("GET / ") {
let buf_reader = BufReader::new(stream);
return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await;
}
// Unknown request -- consume and respond 404
let mut stream = stream;
let mut discard = [0u8; 4096];
let _ = stream.read(&mut discard).await;
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
stream
.write_all(resp.as_bytes())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
const MAX_HEADER_BYTES: usize = 8192;
async fn handle_http_redirect(
buf_reader: BufReader<tokio::net::TcpStream>,
chrome_host_port: String,
proxy_port: u16,
) -> Result<(), String> {
let mut br = buf_reader;
let mut total_bytes = 0usize;
loop {
let mut line = String::new();
let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?;
total_bytes += n;
if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES {
break;
}
}
let location = format!(
"http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws",
chrome_host_port, proxy_port
);
let body = format!(
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
url = location
);
let resp = format!(
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
location,
body.len(),
body
);
let mut stream = br.into_inner();
stream
.write_all(resp.as_bytes())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn handle_ws_proxy(
stream: tokio::net::TcpStream,
proxy: Arc<InspectProxyHandle>,
target_id: String,
) -> Result<(), String> {
let ws_stream = tokio_tungstenite::accept_async(stream)
.await
.map_err(|e| format!("WebSocket handshake failed: {}", e))?;
// Create a dedicated CDP session for this DevTools connection.
// Each connection gets its own session so domain enablements (DOM.enable, etc.)
// always trigger fresh initial state dumps from Chrome.
let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst);
let attach_cmd = format!(
r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#,
attach_id, target_id
);
// Subscribe BEFORE sending so we don't miss the response (tokio broadcast
// receivers only deliver messages to receivers that already exist).
let mut raw_rx = proxy.subscribe_raw();
proxy
.send_raw(attach_cmd)
.await
.map_err(|e| format!("Failed to send attachToTarget: {}", e))?;
// Wait for the attachToTarget response to extract the session ID
let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Ok(raw_msg) = raw_rx.recv().await {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_msg.text) {
if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) {
if let Some(sid) = val
.get("result")
.and_then(|r| r.get("sessionId"))
.and_then(|s| s.as_str())
{
return Ok(sid.to_string());
}
return Err("attachToTarget failed".to_string());
}
}
}
Err("raw message channel closed".to_string())
})
.await
.map_err(|_| "Timed out waiting for attachToTarget response".to_string())?
.map_err(|e| format!("Failed to create DevTools session: {}", e))?;
let (ws_tx, mut ws_rx) = ws_stream.split();
let ws_tx = Arc::new(Mutex::new(ws_tx));
let mut raw_rx = proxy.subscribe_raw();
let ws_tx_clone = ws_tx.clone();
let session_id_clone = session_id.clone();
// Chrome -> DevTools: forward messages matching our session, strip sessionId
let mut chrome_to_devtools = tokio::spawn(async move {
loop {
let raw_msg = match raw_rx.recv().await {
Ok(msg) => msg,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
let _ = writeln!(
std::io::stderr(),
"[inspect] warning: dropped {} CDP messages (channel lag)",
n
);
continue;
}
Err(_) => break,
};
if raw_msg.session_id.as_deref() != Some(&session_id_clone) {
continue;
}
let stripped = strip_session_id(&raw_msg.text);
let mut tx = ws_tx_clone.lock().await;
if tx.send(Message::Text(stripped)).await.is_err() {
break;
}
}
});
// DevTools -> Chrome: inject sessionId and forward
let proxy_for_send = proxy.clone();
let session_id_for_send = session_id.clone();
let mut devtools_to_chrome = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let injected = inject_session_id(&text, &session_id_for_send);
if proxy_for_send.send_raw(injected).await.is_err() {
break;
}
}
});
tokio::select! {
_ = &mut chrome_to_devtools => {
devtools_to_chrome.abort();
},
_ = &mut devtools_to_chrome => {
chrome_to_devtools.abort();
},
}
// Clean up the CDP session so Chrome doesn't leak attached targets
let detach_cmd = format!(
r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#,
ATTACH_ID.fetch_sub(1, Ordering::SeqCst),
session_id
);
let _ = proxy.send_raw(detach_cmd).await;
Ok(())
}
fn inject_session_id(json: &str, session_id: &str) -> String {
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
if let Some(obj) = val.as_object_mut() {
obj.insert(
"sessionId".to_string(),
serde_json::Value::String(session_id.to_string()),
);
}
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
} else {
json.to_string()
}
}
fn strip_session_id(json: &str) -> String {
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
if let Some(obj) = val.as_object_mut() {
obj.remove("sessionId");
}
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
} else {
json.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_session_id() {
let input = r#"{"id":1,"method":"DOM.getDocument"}"#;
let result = inject_session_id(input, "abc123");
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
assert_eq!(parsed["sessionId"], "abc123");
assert_eq!(parsed["method"], "DOM.getDocument");
assert_eq!(parsed["id"], 1);
}
#[test]
fn test_inject_session_id_empty_object() {
let result = inject_session_id("{}", "abc");
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
assert_eq!(parsed["sessionId"], "abc");
}
#[test]
fn test_strip_session_id() {
let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#;
let result = strip_session_id(input);
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
assert!(parsed.get("sessionId").is_none());
assert_eq!(parsed["id"], 1);
}
#[test]
fn test_inject_then_strip_roundtrip() {
let input = r#"{"id":42,"method":"Runtime.evaluate"}"#;
let injected = inject_session_id(input, "sess1");
let stripped = strip_session_id(&injected);
let original: serde_json::Value = serde_json::from_str(input).unwrap();
let result: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(original, result);
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
#[allow(dead_code)]
pub mod actions;
#[allow(dead_code)]
pub mod auth;
#[allow(dead_code)]
pub mod browser;
#[allow(dead_code)]
pub mod cdp;
#[allow(dead_code)]
pub mod cookies;
#[allow(dead_code)]
pub mod daemon;
#[allow(dead_code)]
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;
#[allow(dead_code)]
pub mod policy;
#[allow(dead_code)]
pub mod providers;
#[allow(dead_code)]
pub mod recording;
#[allow(dead_code)]
pub mod screenshot;
#[allow(dead_code)]
pub mod snapshot;
#[allow(dead_code)]
pub mod state;
#[allow(dead_code)]
pub mod storage;
#[allow(dead_code)]
pub mod stream;
#[allow(dead_code)]
pub mod tracing;
#[allow(dead_code)]
pub mod webdriver;
#[cfg(test)]
mod e2e_tests;
#[cfg(test)]
mod parity_tests;
+672
View File
@@ -0,0 +1,672 @@
use serde_json::{json, Value};
use std::collections::HashMap;
use super::cdp::client::CdpClient;
pub async fn set_extra_headers(
client: &CdpClient,
session_id: &str,
headers: &HashMap<String, String>,
) -> Result<(), String> {
let headers_value: Value = headers
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect::<serde_json::Map<String, Value>>()
.into();
client
.send_command(
"Network.setExtraHTTPHeaders",
Some(json!({ "headers": headers_value })),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn set_offline(
client: &CdpClient,
session_id: &str,
offline: bool,
) -> Result<(), String> {
client
.send_command(
"Network.emulateNetworkConditions",
Some(json!({
"offline": offline,
"latency": 0,
"downloadThroughput": -1,
"uploadThroughput": -1,
})),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
// Get current frame ID
let tree_result = client
.send_command_no_params("Page.getFrameTree", Some(session_id))
.await?;
let frame_id = tree_result
.get("frameTree")
.and_then(|t| t.get("frame"))
.and_then(|f| f.get("id"))
.and_then(|id| id.as_str())
.ok_or("Could not determine frame ID")?;
client
.send_command(
"Page.setDocumentContent",
Some(json!({
"frameId": frame_id,
"html": html,
})),
Some(session_id),
)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Domain filter
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct DomainFilter {
pub allowed_domains: Vec<String>,
}
impl DomainFilter {
pub fn new(domains: &str) -> Self {
let allowed = parse_domain_list(domains);
Self {
allowed_domains: allowed,
}
}
pub fn is_allowed(&self, hostname: &str) -> bool {
if self.allowed_domains.is_empty() {
return true;
}
let hostname = hostname.to_lowercase();
for pattern in &self.allowed_domains {
if let Some(suffix) = pattern.strip_prefix("*.") {
if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
return true;
}
} else if hostname == *pattern {
return true;
}
}
false
}
pub fn check_url(&self, url: &str) -> Result<(), String> {
if self.allowed_domains.is_empty() {
return Ok(());
}
let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
let hostname = parsed
.host_str()
.ok_or_else(|| format!("No hostname in URL: {}", url))?;
if self.is_allowed(hostname) {
Ok(())
} else {
Err(format!(
"Domain '{}' is not in the allowed domains list",
hostname
))
}
}
}
fn parse_domain_list(input: &str) -> Vec<String> {
input
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
pub async fn sanitize_existing_pages(
client: &CdpClient,
pages: &[super::browser::PageInfo],
filter: &DomainFilter,
) {
for page in pages {
if page.url.is_empty() || page.url == "about:blank" {
continue;
}
if let Ok(parsed) = url::Url::parse(&page.url) {
if let Some(hostname) = parsed.host_str() {
if !filter.is_allowed(hostname) {
let _ = client
.send_command(
"Page.navigate",
Some(json!({ "url": "about:blank" })),
Some(&page.session_id),
)
.await;
}
}
}
}
}
pub async fn install_domain_filter_script(
client: &CdpClient,
session_id: &str,
allowed_domains: &[String],
) -> Result<(), String> {
if allowed_domains.is_empty() {
return Ok(());
}
let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
let script = format!(
r#"(() => {{
const _allowed = {};
function _isDomainAllowed(hostname) {{
hostname = hostname.toLowerCase();
for (const p of _allowed) {{
if (p.startsWith('*.')) {{
const suffix = p.slice(2);
if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
}} else if (hostname === p) return true;
}}
return false;
}}
const OrigWS = window.WebSocket;
window.WebSocket = function(url, protocols) {{
try {{
const u = new URL(url, location.href);
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
return new OrigWS(url, protocols);
}};
window.WebSocket.prototype = OrigWS.prototype;
const OrigES = window.EventSource;
if (OrigES) {{
window.EventSource = function(url, opts) {{
try {{
const u = new URL(url, location.href);
if (!_isDomainAllowed(u.hostname)) throw new DOMException('EventSource blocked: ' + u.hostname, 'SecurityError');
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
return new OrigES(url, opts);
}};
window.EventSource.prototype = OrigES.prototype;
}}
const origBeacon = navigator.sendBeacon;
if (origBeacon) {{
navigator.sendBeacon = function(url, data) {{
try {{
const u = new URL(url, location.href);
if (!_isDomainAllowed(u.hostname)) return false;
}} catch(e) {{ return false; }}
return origBeacon.call(navigator, url, data);
}};
}}
}})()"#,
domains_json,
);
client
.send_command(
"Page.addScriptToEvaluateOnNewDocument",
Some(json!({ "source": script })),
Some(session_id),
)
.await?;
Ok(())
}
/// Enable Fetch-based network interception for domain filtering.
/// This intercepts all requests and checks them against the allowed domains list.
/// The actual handling of `Fetch.requestPaused` events happens in
/// `resolve_fetch_paused` in the actions module.
pub async fn install_domain_filter_fetch(
client: &CdpClient,
session_id: &str,
handle_auth_requests: bool,
) -> Result<(), String> {
let mut params = json!({
"patterns": [{ "urlPattern": "*" }]
});
if handle_auth_requests {
params["handleAuthRequests"] = json!(true);
}
client
.send_command("Fetch.enable", Some(params), Some(session_id))
.await?;
Ok(())
}
/// Install both layers of domain filtering on a session:
/// 1. JS patching (WebSocket, EventSource, sendBeacon)
/// 2. Fetch-based network interception
pub async fn install_domain_filter(
client: &CdpClient,
session_id: &str,
allowed_domains: &[String],
handle_auth_requests: bool,
) -> Result<(), String> {
install_domain_filter_script(client, session_id, allowed_domains).await?;
install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Console arg formatting (CDP RemoteObject → human-readable string)
// ---------------------------------------------------------------------------
/// Format a single CDP RemoteObject arg into a human-readable string.
/// Priority: value → preview → description.
pub fn format_console_arg(arg: &Value) -> Option<String> {
let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
let subtype = arg.get("subtype").and_then(|v| v.as_str());
if obj_type == "undefined" {
return Some("undefined".to_string());
}
if subtype == Some("null") {
return Some("null".to_string());
}
// Primitive value
if let Some(v) = arg.get("value") {
return Some(match v {
Value::String(s) => s.clone(),
Value::Null => "null".to_string(),
other => other.to_string(),
});
}
// Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
// than their preview properties (which only show "size")
if let Some(preview) = arg.get("preview") {
let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
return arg
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
let is_array = subtype == Some("array") || preview_subtype == Some("array");
if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
let overflow = preview
.get("overflow")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let formatted_props: Vec<String> = props
.iter()
.filter_map(|p| {
let value_str = p.get("value").and_then(|v| v.as_str())?;
let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
let formatted_value = if prop_type == "string" {
format!("\"{}\"", value_str)
} else {
value_str.to_string()
};
if is_array {
Some(formatted_value)
} else {
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
Some(format!("{}: {}", name, formatted_value))
}
})
.collect();
let inner = if overflow {
format!("{}, ...", formatted_props.join(", "))
} else {
formatted_props.join(", ")
};
return if is_array {
Some(format!("[{}]", inner))
} else {
Some(format!("{{{}}}", inner))
};
}
}
// Fallback to description
arg.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Format an array of CDP RemoteObject args into a single space-separated string.
pub fn format_console_args(args: &[Value]) -> String {
args.iter()
.filter_map(format_console_arg)
.collect::<Vec<_>>()
.join(" ")
}
// ---------------------------------------------------------------------------
// Console and error tracking
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct ConsoleEntry {
pub level: String,
pub text: String,
pub args: Vec<Value>,
}
#[derive(Debug, Clone)]
pub struct ErrorEntry {
pub text: String,
pub url: Option<String>,
pub line: Option<i64>,
pub column: Option<i64>,
}
pub struct EventTracker {
pub console_entries: Vec<ConsoleEntry>,
pub error_entries: Vec<ErrorEntry>,
pub max_entries: usize,
}
impl EventTracker {
pub fn new() -> Self {
Self {
console_entries: Vec::new(),
error_entries: Vec::new(),
max_entries: 1000,
}
}
pub fn add_console(&mut self, level: &str, text: &str, args: Vec<Value>) {
if self.console_entries.len() >= self.max_entries {
self.console_entries.remove(0);
}
self.console_entries.push(ConsoleEntry {
level: level.to_string(),
text: text.to_string(),
args,
});
}
pub fn add_error(
&mut self,
text: &str,
url: Option<&str>,
line: Option<i64>,
col: Option<i64>,
) {
if self.error_entries.len() >= self.max_entries {
self.error_entries.remove(0);
}
self.error_entries.push(ErrorEntry {
text: text.to_string(),
url: url.map(String::from),
line,
column: col,
});
}
pub fn clear_console(&mut self) {
self.console_entries.clear();
}
pub fn get_console_json(&self) -> Value {
let messages: Vec<Value> = self
.console_entries
.iter()
.map(|e| {
let mut msg = json!({ "type": e.level, "text": e.text });
if !e.args.is_empty() {
msg.as_object_mut()
.unwrap()
.insert("args".to_string(), Value::Array(e.args.clone()));
}
msg
})
.collect();
json!({ "messages": messages })
}
pub fn get_errors_json(&self) -> Value {
let entries: Vec<Value> = self
.error_entries
.iter()
.map(|e| {
json!({
"text": e.text,
"url": e.url,
"line": e.line,
"column": e.column,
})
})
.collect();
json!({ "errors": entries })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_domain_filter_exact() {
let filter = DomainFilter::new("example.com");
assert!(filter.is_allowed("example.com"));
assert!(!filter.is_allowed("other.com"));
}
#[test]
fn test_domain_filter_wildcard() {
let filter = DomainFilter::new("*.example.com");
assert!(filter.is_allowed("example.com"));
assert!(filter.is_allowed("api.example.com"));
assert!(filter.is_allowed("sub.api.example.com"));
assert!(!filter.is_allowed("other.com"));
}
#[test]
fn test_domain_filter_empty() {
let filter = DomainFilter::new("");
assert!(filter.is_allowed("anything.com"));
}
#[test]
fn test_domain_filter_multiple() {
let filter = DomainFilter::new("example.com, *.api.io");
assert!(filter.is_allowed("example.com"));
assert!(filter.is_allowed("api.io"));
assert!(filter.is_allowed("v1.api.io"));
assert!(!filter.is_allowed("other.com"));
}
#[test]
fn test_parse_domain_list() {
let domains = parse_domain_list("A.com, B.com , *.C.com");
assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
}
#[test]
fn test_event_tracker() {
let mut tracker = EventTracker::new();
tracker.add_console("log", "hello", vec![]);
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
assert_eq!(tracker.console_entries.len(), 1);
assert_eq!(tracker.error_entries.len(), 1);
}
#[test]
fn test_console_json_includes_args() {
let mut tracker = EventTracker::new();
let raw_args = vec![
json!({"type": "string", "value": "hello"}),
json!({"type": "number", "value": 42}),
];
tracker.add_console("log", "hello 42", raw_args);
let result = tracker.get_console_json();
let messages = result.get("messages").unwrap().as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].get("text").unwrap(), "hello 42");
let args = messages[0].get("args").unwrap().as_array().unwrap();
assert_eq!(args.len(), 2);
assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
assert_eq!(args[1], json!({"type": "number", "value": 42}));
}
#[test]
fn test_console_json_empty_args_omits_field() {
let mut tracker = EventTracker::new();
tracker.add_console("log", "text only", vec![]);
let result = tracker.get_console_json();
let messages = result.get("messages").unwrap().as_array().unwrap();
assert!(messages[0].get("args").is_none());
}
// -- format_console_arg: primitives --
#[test]
fn test_format_arg_string() {
let arg = json!({"type": "string", "value": "hello"});
assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
}
#[test]
fn test_format_arg_number() {
let arg = json!({"type": "number", "value": 42});
assert_eq!(format_console_arg(&arg), Some("42".to_string()));
}
#[test]
fn test_format_arg_null() {
let arg = json!({"type": "object", "subtype": "null", "value": null});
assert_eq!(format_console_arg(&arg), Some("null".to_string()));
}
#[test]
fn test_format_arg_undefined() {
let arg = json!({"type": "undefined"});
assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
}
// -- format_console_arg: objects with preview --
#[test]
fn test_format_arg_object_preview() {
let arg = json!({
"type": "object",
"preview": {
"properties": [
{"name": "userId", "type": "string", "value": "abc123"},
{"name": "count", "type": "number", "value": "42"}
],
"overflow": false
}
});
assert_eq!(
format_console_arg(&arg),
Some("{userId: \"abc123\", count: 42}".to_string())
);
}
#[test]
fn test_format_arg_object_preview_overflow() {
let arg = json!({
"type": "object",
"preview": {
"properties": [
{"name": "a", "type": "number", "value": "1"}
],
"overflow": true
}
});
assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
}
// -- format_console_arg: arrays with preview --
#[test]
fn test_format_arg_array_preview() {
let arg = json!({
"type": "object",
"subtype": "array",
"preview": {
"subtype": "array",
"properties": [
{"name": "0", "type": "number", "value": "1"},
{"name": "1", "type": "number", "value": "2"},
{"name": "2", "type": "number", "value": "3"}
],
"overflow": false
}
});
assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
}
// -- format_console_arg: map/set use description --
#[test]
fn test_format_arg_map_uses_description() {
let arg = json!({
"type": "object",
"subtype": "map",
"description": "Map(1)",
"preview": {
"subtype": "map",
"properties": [{"name": "size", "type": "number", "value": "1"}]
}
});
assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
}
// -- format_console_arg: fallback --
#[test]
fn test_format_arg_description_fallback() {
let arg = json!({"type": "object", "description": "RegExp"});
assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
}
#[test]
fn test_format_arg_no_value_no_preview_no_description() {
let arg = json!({"type": "object"});
assert_eq!(format_console_arg(&arg), None);
}
// -- format_console_args --
#[test]
fn test_format_console_args_join() {
let args = vec![
json!({"type": "string", "value": "user"}),
json!({
"type": "object",
"preview": {
"properties": [{"name": "id", "type": "number", "value": "1"}],
"overflow": false
}
}),
];
assert_eq!(format_console_args(&args), "user {id: 1}");
}
#[test]
fn test_format_console_args_filters_none() {
// An arg that returns None should be skipped, not produce empty string
let args = vec![
json!({"type": "string", "value": "before"}),
json!({"type": "object"}), // no value, preview, or description → None
json!({"type": "string", "value": "after"}),
];
assert_eq!(format_console_args(&args), "before after");
}
}
+700
View File
@@ -0,0 +1,700 @@
//! Parity tests for the native daemon's command interface.
//!
//! These unit tests verify:
//! - All documented actions are handled (not returning "Not yet implemented")
//! - Response format consistency (success/error structure)
//! - Credential and state actions work without a browser
use serde_json::{json, Value};
use super::actions::{execute_command, DaemonState};
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
struct TestKeyGuard {
_lock: std::sync::MutexGuard<'static, ()>,
original: Option<String>,
}
impl TestKeyGuard {
fn new() -> Self {
let lock = super::auth::AUTH_TEST_MUTEX
.lock()
.unwrap_or_else(|e| e.into_inner());
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
Self {
_lock: lock,
original,
}
}
}
impl Drop for TestKeyGuard {
fn drop(&mut self) {
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
match &self.original {
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
}
}
}
/// All documented action names that should be implemented.
const DOCUMENTED_ACTIONS: &[&str] = &[
"launch",
"navigate",
"url",
"title",
"content",
"evaluate",
"close",
"snapshot",
"screenshot",
"click",
"dblclick",
"fill",
"type",
"press",
"hover",
"scroll",
"select",
"check",
"uncheck",
"wait",
"gettext",
"getattribute",
"isvisible",
"isenabled",
"ischecked",
"back",
"forward",
"reload",
"cookies_get",
"cookies_set",
"cookies_clear",
"storage_get",
"storage_set",
"storage_clear",
"setcontent",
"headers",
"offline",
"console",
"errors",
"state_save",
"state_load",
"state_list",
"state_show",
"state_clear",
"state_clean",
"state_rename",
"trace_start",
"trace_stop",
"profiler_start",
"profiler_stop",
"recording_start",
"recording_stop",
"recording_restart",
"pdf",
"tab_list",
"tab_new",
"tab_switch",
"tab_close",
"viewport",
"user_agent",
"set_media",
"download",
"diff_snapshot",
"diff_url",
"credentials_set",
"credentials_get",
"credentials_delete",
"credentials_list",
"mouse",
"keyboard",
"focus",
"clear",
"selectall",
"scrollintoview",
"dispatch",
"highlight",
"tap",
"boundingbox",
"innertext",
"innerhtml",
"inputvalue",
"setvalue",
"count",
"styles",
"bringtofront",
"timezone",
"locale",
"geolocation",
"permissions",
"dialog",
"upload",
"addscript",
"addinitscript",
"addstyle",
"clipboard",
"wheel",
"device",
"screencast_start",
"screencast_stop",
"waitforurl",
"waitforloadstate",
"waitforfunction",
"frame",
"mainframe",
"getbyrole",
"getbytext",
"getbylabel",
"getbyplaceholder",
"getbyalttext",
"getbytitle",
"getbytestid",
"nth",
"find",
"evalhandle",
"drag",
"expose",
"pause",
"multiselect",
"responsebody",
"waitfordownload",
"window_new",
"diff_screenshot",
"video_start",
"video_stop",
"har_start",
"har_stop",
"route",
"unroute",
"requests",
"request_detail",
"credentials",
"auth_save",
"auth_login",
"auth_list",
"auth_delete",
"auth_show",
"confirm",
"deny",
"swipe",
"device_list",
"input_mouse",
"input_keyboard",
"input_touch",
"keydown",
"keyup",
"inserttext",
"mousemove",
"mousedown",
"mouseup",
];
fn minimal_command(action: &str, id: &str) -> Value {
let mut cmd = json!({ "action": action, "id": id });
let obj = cmd.as_object_mut().unwrap();
match action {
"navigate" | "diff_url" | "waitforurl" => {
obj.insert("url".to_string(), json!("https://example.com"));
}
"evaluate" | "expose" => {
obj.insert("script".to_string(), json!("1"));
}
"click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select"
| "check" | "uncheck" | "gettext" | "getattribute" | "isvisible" | "isenabled"
| "ischecked" | "focus" | "clear" | "selectall" | "scrollintoview" | "dispatch"
| "highlight" | "tap" | "boundingbox" | "innertext" | "innerhtml" | "inputvalue"
| "setvalue" | "count" | "find" | "nth" | "getbytext" | "getbylabel"
| "getbyplaceholder" | "getbyalttext" | "getbytitle" | "getbytestid" => {
obj.insert("selector".to_string(), json!("body"));
}
"getbyrole" => {
obj.insert("role".to_string(), json!("button"));
obj.insert("selector".to_string(), json!("body"));
}
"setcontent" => {
obj.insert("html".to_string(), json!("<html></html>"));
}
"cookies_set" => {
obj.insert("name".to_string(), json!("test"));
obj.insert("value".to_string(), json!("val"));
}
"storage_get" | "storage_set" | "storage_clear" => {
obj.insert("origin".to_string(), json!("https://example.com"));
}
"state_save" | "state_load" | "state_show" | "state_clear" => {
obj.insert("path".to_string(), json!("test-parity-state.json"));
}
"state_rename" => {
obj.insert("path".to_string(), json!("test-parity-state.json"));
obj.insert("name".to_string(), json!("renamed"));
}
"state_clean" => {
obj.insert("days".to_string(), json!(7));
}
"credentials_set" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
obj.insert("username".to_string(), json!("u"));
obj.insert("password".to_string(), json!("p"));
}
"auth_save" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
obj.insert("url".to_string(), json!("https://example.com"));
obj.insert("username".to_string(), json!("u"));
obj.insert("password".to_string(), json!("p"));
}
"credentials_get" | "credentials_delete" | "auth_show" | "auth_delete" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
}
"tab_switch" | "tab_close" => {
obj.insert("index".to_string(), json!(0));
}
"viewport" | "user_agent" | "set_media" | "timezone" | "locale" | "geolocation"
| "permissions" | "device" => {
obj.insert("value".to_string(), json!(null));
}
"headers" => {
obj.insert("headers".to_string(), json!({}));
}
"offline" => {
obj.insert("offline".to_string(), json!(false));
}
"wait" => {
obj.insert("timeout".to_string(), json!(100));
}
"waitforloadstate" => {
obj.insert("state".to_string(), json!("load"));
}
"waitforfunction" => {
obj.insert("script".to_string(), json!("() => true"));
}
"frame" => {
obj.insert("selector".to_string(), json!("iframe"));
}
"addscript" => {
obj.insert("content".to_string(), json!("console.log('test')"));
}
"addinitscript" => {
obj.insert("script".to_string(), json!("console.log('init')"));
}
"addstyle" => {
obj.insert("content".to_string(), json!("body { color: red }"));
}
"wheel" => {
obj.insert("deltaX".to_string(), json!(0));
obj.insert("deltaY".to_string(), json!(0));
}
"upload" => {
obj.insert("selector".to_string(), json!("input[type=file]"));
obj.insert("files".to_string(), json!([]));
}
"dialog" => {
obj.insert("accept".to_string(), json!(true));
}
"credentials" => {
obj.insert("username".to_string(), json!("u"));
obj.insert("password".to_string(), json!("p"));
}
"auth_login" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
}
"route" => {
obj.insert("url".to_string(), json!("*"));
obj.insert("handler".to_string(), json!("continue"));
}
"diff_snapshot" | "diff_screenshot" => {
obj.insert("selector".to_string(), json!("body"));
}
"recording_start" | "recording_restart" => {
obj.insert("path".to_string(), json!("/tmp/parity-recording.webm"));
}
"video_start" => {
obj.insert("path".to_string(), json!("/tmp/parity-video.webm"));
}
"profiler_start" => {
obj.insert("path".to_string(), json!("/tmp/parity-profile"));
}
"trace_stop" | "har_stop" => {
obj.insert("path".to_string(), json!("/tmp/parity-trace"));
}
"download" => {
obj.insert("path".to_string(), json!("/tmp/parity-download"));
}
"multiselect" => {
obj.insert("selector".to_string(), json!("select"));
obj.insert("values".to_string(), json!([]));
}
"responsebody" => {
obj.insert("url".to_string(), json!("https://example.com"));
}
"waitfordownload" => {
obj.insert("path".to_string(), json!("/tmp/parity-download"));
}
"styles" => {
obj.insert("selector".to_string(), json!("body"));
obj.insert("names".to_string(), json!([]));
}
"evalhandle" => {
obj.insert("handle".to_string(), json!(""));
obj.insert("script".to_string(), json!("h => h"));
}
"drag" => {
obj.insert("source".to_string(), json!("body"));
obj.insert("target".to_string(), json!("body"));
}
"swipe" => {
obj.insert("selector".to_string(), json!("body"));
obj.insert("direction".to_string(), json!("left"));
}
"input_mouse" | "mousemove" | "mousedown" | "mouseup" => {
obj.insert("x".to_string(), json!(100));
obj.insert("y".to_string(), json!(100));
}
"input_keyboard" | "keydown" | "keyup" => {
obj.insert("key".to_string(), json!("a"));
}
"input_touch" => {
obj.insert("type".to_string(), json!("touchStart"));
obj.insert("touchPoints".to_string(), json!([]));
}
"inserttext" => {
obj.insert("text".to_string(), json!("test"));
}
_ => {}
}
cmd
}
// ---------------------------------------------------------------------------
// 1. Action dispatch coverage
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_all_documented_actions_are_handled() {
let mut state = DaemonState::new();
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
let id = format!("parity-{}", i);
let cmd = minimal_command(action, &id);
let result = execute_command(&cmd, &mut state).await;
assert!(
result.get("id").is_some(),
"Action '{}': response missing 'id'",
action
);
let error = result.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!error.contains("Not yet implemented"),
"Action '{}' returned 'Not yet implemented')",
action
);
}
}
// ---------------------------------------------------------------------------
// 2. Response format consistency
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_success_response_format() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "state_list", "id": "fmt-1" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
assert!(result.get("id").is_some());
assert!(result.get("data").is_some());
assert!(result.get("error").is_none());
}
#[tokio::test]
async fn test_error_response_format() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "nonexistent_action_xyz", "id": "fmt-2" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], false);
assert!(result.get("id").is_some());
assert!(result.get("error").is_some());
}
// ---------------------------------------------------------------------------
// 3. Credential/state actions work without a browser
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_state_list_without_browser() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "state_list", "id": "nb-1" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
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();
// har_start requires a browser. Because execute_command auto-launches when
// no browser is present, the result depends on Chrome availability: success
// if Chrome is found (CI), failure if not. Both outcomes are valid.
let cmd = json!({ "action": "har_start", "id": "har-1" });
let result = execute_command(&cmd, &mut state).await;
let success = result["success"].as_bool().unwrap_or(false);
if success {
assert!(state.har_recording);
} else {
assert!(result["error"].as_str().is_some());
}
}
#[tokio::test]
async fn test_state_clean_action() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "state_clean", "id": "clean-1", "days": 30 });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
}
#[tokio::test]
async fn test_daemon_state_new_defaults() {
let state = DaemonState::new();
assert!(state.browser.is_none());
assert!(!state.har_recording);
assert!(state.har_entries.is_empty());
assert!(state.pending_confirmation.is_none());
assert!(!state.request_tracking);
assert!(state.tracked_requests.is_empty());
assert!(state.active_frame_id.is_none());
assert!(state.webdriver_backend.is_none());
assert!(state.stream_client.is_none());
}
#[tokio::test]
async fn test_tracked_request_struct() {
use super::actions::TrackedRequest;
let tr = TrackedRequest {
url: "https://example.com/api".to_string(),
method: "GET".to_string(),
headers: json!({"Accept": "text/html"}),
timestamp: 12345,
resource_type: "Document".to_string(),
request_id: "1.1".to_string(),
post_data: None,
status: Some(200),
response_headers: None,
mime_type: Some("text/html".to_string()),
};
let serialized = serde_json::to_value(&tr).unwrap();
assert_eq!(serialized["url"], "https://example.com/api");
assert_eq!(serialized["method"], "GET");
assert_eq!(serialized["resourceType"], "Document");
assert_eq!(serialized["timestamp"], 12345);
}
#[tokio::test]
async fn test_request_tracking_state() {
let mut state = DaemonState::new();
assert!(!state.request_tracking);
assert!(state.tracked_requests.is_empty());
state.tracked_requests.push(super::actions::TrackedRequest {
url: "https://example.com".to_string(),
method: "GET".to_string(),
headers: json!({}),
timestamp: 1,
resource_type: "Document".to_string(),
request_id: "1.1".to_string(),
post_data: None,
status: None,
response_headers: None,
mime_type: None,
});
state.tracked_requests.push(super::actions::TrackedRequest {
url: "https://other.com".to_string(),
method: "POST".to_string(),
headers: json!({}),
timestamp: 2,
resource_type: "XHR".to_string(),
request_id: "1.2".to_string(),
post_data: None,
status: None,
response_headers: None,
mime_type: None,
});
assert_eq!(state.tracked_requests.len(), 2);
// Filter
let filtered: Vec<_> = state
.tracked_requests
.iter()
.filter(|r| r.url.contains("example"))
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].url, "https://example.com");
// Clear
state.tracked_requests.clear();
assert!(state.tracked_requests.is_empty());
}
#[test]
fn test_matches_status_filter() {
use super::actions::matches_status_filter;
// Exact match
assert!(matches_status_filter(Some(200), "200"));
assert!(!matches_status_filter(Some(201), "200"));
// Class match (Nxx)
assert!(matches_status_filter(Some(200), "2xx"));
assert!(matches_status_filter(Some(299), "2xx"));
assert!(!matches_status_filter(Some(301), "2xx"));
assert!(matches_status_filter(Some(404), "4xx"));
// Range match
assert!(matches_status_filter(Some(400), "400-499"));
assert!(matches_status_filter(Some(499), "400-499"));
assert!(!matches_status_filter(Some(500), "400-499"));
// None status
assert!(!matches_status_filter(None, "200"));
assert!(!matches_status_filter(None, "2xx"));
}
#[tokio::test]
async fn test_addscript_and_addinitscript_separate_dispatch() {
let mut state = DaemonState::new();
// Both should be handled (not "Not yet implemented") even without a browser
let cmd1 = json!({ "action": "addscript", "id": "as-1", "content": "console.log(1)" });
let result1 = execute_command(&cmd1, &mut state).await;
let err1 = result1["error"].as_str().unwrap_or("");
assert!(
!err1.contains("Not yet implemented"),
"addscript should be handled"
);
let cmd2 = json!({ "action": "addinitscript", "id": "ais-1", "script": "console.log(2)" });
let result2 = execute_command(&cmd2, &mut state).await;
let err2 = result2["error"].as_str().unwrap_or("");
assert!(
!err2.contains("Not yet implemented"),
"addinitscript should be handled"
);
}
#[tokio::test]
async fn test_frame_context_management() {
let mut state = DaemonState::new();
assert!(state.active_frame_id.is_none());
// Set a frame ID and verify it persists
state.active_frame_id = Some("child-frame-123".to_string());
assert_eq!(state.active_frame_id.as_deref(), Some("child-frame-123"));
// Clearing the frame ID (what mainframe does)
state.active_frame_id = None;
assert!(state.active_frame_id.is_none());
}
#[tokio::test]
async fn test_addstyle_supports_content_and_url() {
let mut state = DaemonState::new();
// Both content-based and url-based addstyle should be recognized
let cmd1 = json!({ "action": "addstyle", "id": "style-1", "content": "body { color: red }" });
let result1 = execute_command(&cmd1, &mut state).await;
let err1 = result1["error"].as_str().unwrap_or("");
assert!(!err1.contains("Not yet implemented"));
let cmd2 =
json!({ "action": "addstyle", "id": "style-2", "url": "https://example.com/style.css" });
let result2 = execute_command(&cmd2, &mut state).await;
let err2 = result2["error"].as_str().unwrap_or("");
assert!(!err2.contains("Not yet implemented"));
}
#[tokio::test]
async fn test_domain_filter_sanitize() {
use super::network::DomainFilter;
let filter = DomainFilter::new("example.com");
assert!(filter.is_allowed("example.com"));
assert!(!filter.is_allowed("evil.com"));
filter.check_url("https://example.com/path").unwrap();
assert!(filter.check_url("https://evil.com").is_err());
}
#[tokio::test]
async fn test_state_find_auto_returns_none_for_nonexistent() {
use super::state;
let result = state::find_auto_state_file("nonexistent-session-xyz");
assert!(result.is_none());
}
+217
View File
@@ -0,0 +1,217 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::PathBuf;
/// Result of a policy check for an action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyResult {
/// Action is allowed.
Allow,
/// Action is blocked with the given reason.
Deny(String),
/// Action requires confirmation before proceeding.
RequiresConfirmation,
}
/// Policy configuration loaded from a JSON file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionPolicy {
#[serde(skip)]
path: PathBuf,
#[serde(default)]
default: Option<String>,
#[serde(default)]
allow: Option<Vec<String>>,
#[serde(default)]
deny: Option<Vec<String>>,
#[serde(default)]
confirm: Option<Vec<String>>,
}
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
#[derive(Debug, Clone)]
pub struct ConfirmActions {
pub categories: HashSet<String>,
}
impl ConfirmActions {
pub fn from_env() -> Option<Self> {
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
if val.is_empty() {
return None;
}
let categories: HashSet<String> = val
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if categories.is_empty() {
None
} else {
Some(Self { categories })
}
}
pub fn requires_confirmation(&self, action: &str) -> bool {
self.categories.contains(action)
}
}
impl ActionPolicy {
/// Load policy from a JSON file at the given path.
pub fn load(path: &str) -> Result<Self, String> {
let path_buf = PathBuf::from(path);
let contents = fs::read_to_string(&path_buf)
.map_err(|e| format!("Failed to read policy file: {}", e))?;
let mut policy: ActionPolicy =
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
policy.path = path_buf;
Ok(policy)
}
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
pub fn load_if_exists() -> Option<Self> {
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
.ok()?;
Self::load(&path).ok()
}
/// Check whether an action is allowed, denied, or requires confirmation.
pub fn check(&self, action: &str) -> PolicyResult {
if let Some(deny) = &self.deny {
if deny.iter().any(|a| a == action) {
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
}
}
if let Some(confirm) = &self.confirm {
if confirm.iter().any(|a| a == action) {
return PolicyResult::RequiresConfirmation;
}
}
if let Some(allow) = &self.allow {
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
let is_default_deny = self
.default
.as_deref()
.map(|d| d.eq_ignore_ascii_case("deny"))
.unwrap_or(true);
if is_default_deny {
return PolicyResult::Deny(format!(
"Action '{}' is not in the allow list",
action
));
}
}
} else if let Some(ref default) = self.default {
if default.eq_ignore_ascii_case("deny") {
return PolicyResult::Deny(format!(
"Action '{}' denied: default policy is deny",
action
));
}
}
PolicyResult::Allow
}
/// Reload policy from the file. Re-reads the JSON and updates the policy.
pub fn reload(&mut self) -> Result<(), String> {
let contents = fs::read_to_string(&self.path)
.map_err(|e| format!("Failed to read policy file: {}", e))?;
let mut policy: ActionPolicy =
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
policy.path = self.path.clone();
*self = policy;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[test]
fn test_policy_allow_whitelist() {
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("click"), PolicyResult::Allow);
assert_eq!(policy.check("type"), PolicyResult::Allow);
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_deny() {
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_confirm() {
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
}
#[test]
fn test_policy_deny_takes_precedence() {
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_confirm_takes_precedence_over_allow() {
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
}
#[test]
fn test_policy_empty_allow_allows_all() {
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("anything"), PolicyResult::Allow);
}
#[test]
fn test_policy_missing_allow_allows_all() {
let json = r#"{"deny": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("anything"), PolicyResult::Allow);
}
#[test]
fn test_policy_default_allow() {
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("click"), PolicyResult::Allow);
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_default_deny() {
let json = r#"{"default": "deny", "allow": ["click"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("click"), PolicyResult::Allow);
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
}
#[test]
fn test_confirm_actions_from_env() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
_guard.set("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
let ca = ConfirmActions::from_env().unwrap();
assert!(ca.requires_confirmation("navigate"));
assert!(ca.requires_confirmation("click"));
assert!(ca.requires_confirmation("fill"));
assert!(!ca.requires_confirmation("screenshot"));
}
}
+816
View File
@@ -0,0 +1,816 @@
//! Browser provider connections for remote CDP sessions.
//!
//! Supports AgentCore, Browserbase, Browserless, Browser Use, and Kernel providers.
//! Each provider returns a CDP WebSocket URL for connecting via BrowserManager.
use serde_json::{json, Value};
use std::env;
/// Provider session info for cleanup on failure.
#[derive(Debug)]
pub struct ProviderSession {
pub provider: String,
pub session_id: String,
}
#[derive(Debug)]
pub struct ProviderConnection {
pub ws_url: String,
pub session: Option<ProviderSession>,
/// If true, the WebSocket IS the page session (no Target.* commands).
pub direct_page: bool,
}
/// Connects to the specified browser provider and returns a CDP WebSocket URL
/// along with session info for cleanup on failure.
pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
match provider_name.to_lowercase().as_str() {
"browserbase" => {
let (url, session) = connect_browserbase().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
"browserless" => {
let (url, session) = connect_browserless().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
"browser-use" | "browseruse" => {
let (url, session) = connect_browser_use().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
"kernel" => {
let (url, session) = connect_kernel().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
"agentcore" => {
let (url, session) = connect_agentcore().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
_ => Err(format!(
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel, agentcore",
provider_name
)),
}
}
/// Close a provider session (call on CDP connect failure).
pub async fn close_provider_session(session: &ProviderSession) {
let client = reqwest::Client::new();
match session.provider.as_str() {
"browserbase" => {
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
let _ = client
.post(format!(
"https://api.browserbase.com/v1/sessions/{}",
session.session_id
))
.header("Content-Type", "application/json")
.header("X-BB-API-Key", &api_key)
.json(&serde_json::json!({ "status": "REQUEST_RELEASE" }))
.send()
.await;
}
}
"browser-use" => {
if let Ok(api_key) = env::var("BROWSER_USE_API_KEY") {
let _ = client
.patch(format!(
"https://api.browser-use.com/api/v2/browsers/{}",
session.session_id
))
.header("X-Browser-Use-API-Key", &api_key)
.header("Content-Type", "application/json")
.json(&json!({ "action": "stop" }))
.send()
.await;
}
}
"browserless" => {
// session_id holds the stop URL for browserless
let _ = client.delete(&session.session_id).send().await;
}
"kernel" => {
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
let endpoint = env::var("KERNEL_ENDPOINT")
.unwrap_or_else(|_| "https://api.onkernel.com".to_string());
let _ = client
.delete(format!(
"{}/browsers/{}",
endpoint.trim_end_matches('/'),
session.session_id
))
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await;
}
}
"agentcore" => {
// AgentCore session cleanup is handled via signed DELETE request
let _ = close_agentcore_session(&session.session_id).await;
}
_ => {}
}
}
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
let api_key = env::var("BROWSERBASE_API_KEY")
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
let client = reqwest::Client::new();
let response = client
.post("https://api.browserbase.com/v1/sessions")
.header("content-type", "application/json")
.header("x-bb-api-key", &api_key)
.body("{}")
.send()
.await
.map_err(|e| format!("Browserbase request failed: {}", e))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read Browserbase response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Browserbase API error ({}): {}",
status.as_u16(),
body
));
}
let json: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserbase response: {}", e))?;
let session_id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("connectUrl")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browserbase response missing connectUrl".to_string())?;
Ok((
ws_url,
Some(ProviderSession {
provider: "browserbase".to_string(),
session_id,
}),
))
}
async fn connect_browserless() -> Result<(String, Option<ProviderSession>), String> {
let api_key = env::var("BROWSERLESS_API_KEY")
.map_err(|_| "BROWSERLESS_API_KEY environment variable is not set")?;
let api_url = env::var("BROWSERLESS_API_URL")
.unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string());
let browser_type =
env::var("BROWSERLESS_BROWSER_TYPE").unwrap_or_else(|_| "chromium".to_string());
let supported = ["chromium", "chrome"];
if !supported.contains(&browser_type.as_str()) {
return Err(format!(
"BROWSERLESS_BROWSER_TYPE \"{}\" is not supported. Only {} are allowed.",
browser_type,
supported.join(", ")
));
}
let ttl: u64 = env::var("BROWSERLESS_TTL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300000);
let stealth = env::var("BROWSERLESS_STEALTH")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(true);
let url = format!("{}/session", api_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let response = client
.post(&url)
.query(&[("token", &api_key)])
.header("Content-Type", "application/json")
.json(&json!({
"ttl": ttl,
"stealth": stealth,
"browser": browser_type,
}))
.send()
.await
.map_err(|e| format!("Browserless request failed: {}", e))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read Browserless response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Browserless API error ({}): {}",
status.as_u16(),
body
));
}
let json: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?;
let connect_url = json
.get("connect")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browserless response missing 'connect' URL".to_string())?;
let stop_url = json
.get("stop")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browserless response missing 'stop' URL".to_string())?;
Ok((
connect_url,
Some(ProviderSession {
provider: "browserless".to_string(),
// Store the stop URL as the session_id for cleanup
session_id: stop_url,
}),
))
}
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
let api_key = env::var("BROWSER_USE_API_KEY")
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
let ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
Ok((ws_url, None))
}
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
let api_key = env::var("KERNEL_API_KEY").ok();
let endpoint =
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
let url = format!("{}/browsers", endpoint.trim_end_matches('/'));
let headless = env::var("KERNEL_HEADLESS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(true);
let stealth = env::var("KERNEL_STEALTH")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let timeout_seconds = env::var("KERNEL_TIMEOUT_SECONDS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(300);
let mut body = json!({
"headless": headless,
"stealth": stealth,
"timeout_seconds": timeout_seconds,
});
if let Ok(profile) = env::var("KERNEL_PROFILE_NAME") {
if !profile.is_empty() {
body.as_object_mut()
.unwrap()
.insert("profile".to_string(), json!(profile));
}
}
let client = reqwest::Client::new();
let mut request = client.post(&url).header("Content-Type", "application/json");
if let Some(ref key) = api_key {
request = request.header("Authorization", format!("Bearer {}", key));
}
let response = request
.json(&body)
.send()
.await
.map_err(|e| format!("Kernel request failed: {}", e))?;
let status = response.status();
let resp_body = response
.text()
.await
.map_err(|e| format!("Failed to read Kernel response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Kernel API error ({}): {}",
status.as_u16(),
resp_body
));
}
let json: Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid Kernel response: {}", e))?;
let session_id = json
.get("session_id")
.or_else(|| json.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("cdp_ws_url")
.or_else(|| json.get("connectUrl"))
.or_else(|| json.get("connect_url"))
.or_else(|| json.get("cdpUrl"))
.or_else(|| json.get("cdp_url"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| {
"Kernel response missing cdp_ws_url, connectUrl, connect_url, cdpUrl, or cdp_url"
.to_string()
})?;
Ok((
ws_url,
Some(ProviderSession {
provider: "kernel".to_string(),
session_id,
}),
))
}
// ============================================================================
// AgentCore Provider (AWS Bedrock AgentCore Browser)
// ============================================================================
mod agentcore {
use super::*;
/// AgentCore-specific session info for Live View URL
pub struct AgentCoreSessionInfo {
pub session_id: String,
pub browser_identifier: String,
pub region: String,
pub live_view_url: String,
}
thread_local! {
static AGENTCORE_INFO: std::cell::RefCell<Option<AgentCoreSessionInfo>> = const { std::cell::RefCell::new(None) };
static AGENTCORE_WS_HEADERS: std::cell::RefCell<Option<Vec<(String, String)>>> = const { std::cell::RefCell::new(None) };
}
pub fn set_agentcore_info(info: AgentCoreSessionInfo) {
AGENTCORE_INFO.with(|cell| *cell.borrow_mut() = Some(info));
}
pub fn get_agentcore_info() -> Option<AgentCoreSessionInfo> {
AGENTCORE_INFO.with(|cell| {
cell.borrow().as_ref().map(|i| AgentCoreSessionInfo {
session_id: i.session_id.clone(),
browser_identifier: i.browser_identifier.clone(),
region: i.region.clone(),
live_view_url: i.live_view_url.clone(),
})
})
}
pub fn set_agentcore_ws_headers(headers: Vec<(String, String)>) {
AGENTCORE_WS_HEADERS.with(|cell| *cell.borrow_mut() = Some(headers));
}
pub fn take_agentcore_ws_headers() -> Option<Vec<(String, String)>> {
AGENTCORE_WS_HEADERS.with(|cell| cell.borrow_mut().take())
}
pub async fn connect() -> Result<(String, Option<ProviderSession>), String> {
let region = env::var("AGENTCORE_REGION")
.or_else(|_| env::var("AWS_REGION"))
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string());
let browser_id =
env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
let timeout_secs: u64 = env::var("AGENTCORE_SESSION_TIMEOUT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3600);
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
let path = format!(
"/browsers/{}/sessions/start",
urlencoding::encode(&browser_id)
);
let url = format!("https://{}{}", host, path);
// Generate a unique session name
let session_name = format!("agent-browser-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut body_json = json!({
"name": session_name,
"sessionTimeoutSeconds": timeout_secs
});
if let Ok(profile_id) = env::var("AGENTCORE_PROFILE_ID") {
if !profile_id.is_empty() {
body_json.as_object_mut().unwrap().insert(
"profileConfiguration".to_string(),
json!({ "profileIdentifier": profile_id }),
);
}
}
let body = serde_json::to_string(&body_json)
.map_err(|e| format!("Failed to serialize request body: {}", e))?;
let signed_headers = sign_request("PUT", &url, &region, Some(&body)).await?;
let client = reqwest::Client::new();
let mut req = client.put(&url).body(body.clone());
for (key, value) in &signed_headers {
req = req.header(key.as_str(), value.as_str());
}
let response = req
.send()
.await
.map_err(|e| format!("AgentCore request failed: {}", e))?;
let status = response.status();
let resp_body = response
.text()
.await
.map_err(|e| format!("Failed to read AgentCore response: {}", e))?;
if !status.is_success() {
return Err(format!(
"AgentCore API error ({}): {}",
status.as_u16(),
resp_body
));
}
let json: Value = serde_json::from_str(&resp_body)
.map_err(|e| format!("Invalid AgentCore response: {}", e))?;
let session_id = json
.get("sessionId")
.and_then(|v| v.as_str())
.ok_or_else(|| "AgentCore response missing sessionId".to_string())?
.to_string();
let browser_identifier = json
.get("browserIdentifier")
.and_then(|v| v.as_str())
.unwrap_or(&browser_id)
.to_string();
let live_view_url = format!(
"https://{}.console.aws.amazon.com/bedrock-agentcore/browser/{}/session/{}#",
region, browser_identifier, session_id
);
set_agentcore_info(AgentCoreSessionInfo {
session_id: session_id.clone(),
browser_identifier: browser_identifier.clone(),
region: region.clone(),
live_view_url: live_view_url.clone(),
});
eprintln!("Session: {}", session_id);
eprintln!("Live View: {}", live_view_url);
let ws_path = format!(
"/browser-streams/{}/sessions/{}/automation",
browser_identifier, session_id
);
let ws_url = format!("wss://{}{}", host, ws_path);
let ws_headers = sign_request(
"GET",
&format!("https://{}{}", host, ws_path),
&region,
None,
)
.await?;
set_agentcore_ws_headers(ws_headers);
Ok((
ws_url,
Some(ProviderSession {
provider: "agentcore".to_string(),
session_id,
}),
))
}
/// Get AWS credentials from environment variables or AWS CLI
fn get_aws_credentials() -> Result<(String, String, Option<String>), String> {
// First try environment variables
if let (Ok(access_key), Ok(secret_key)) = (
env::var("AWS_ACCESS_KEY_ID"),
env::var("AWS_SECRET_ACCESS_KEY"),
) {
return Ok((access_key, secret_key, env::var("AWS_SESSION_TOKEN").ok()));
}
// Fall back to AWS CLI
let mut cmd = std::process::Command::new("aws");
cmd.args(["configure", "export-credentials", "--format", "env"]);
// Honor AWS_PROFILE
if let Ok(profile) = env::var("AWS_PROFILE") {
cmd.args(["--profile", &profile]);
}
let output = cmd.output()
.map_err(|e| format!("Failed to run aws CLI: {}. Install AWS CLI or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"AWS CLI failed: {}. Run 'aws sso login' or set credentials",
stderr.trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut access_key = None;
let mut secret_key = None;
let mut session_token = None;
for line in stdout.lines() {
if let Some(val) = line.strip_prefix("export AWS_ACCESS_KEY_ID=") {
access_key = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("export AWS_SECRET_ACCESS_KEY=") {
secret_key = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("export AWS_SESSION_TOKEN=") {
session_token = Some(val.to_string());
}
}
match (access_key, secret_key) {
(Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
_ => Err("Failed to parse credentials from AWS CLI output".to_string()),
}
}
async fn sign_request(
method: &str,
url: &str,
region: &str,
body: Option<&str>,
) -> Result<Vec<(String, String)>, String> {
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
// Get credentials from environment or AWS CLI
let (access_key, secret_key, session_token) = get_aws_credentials()?;
let parsed_url = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
let host = parsed_url.host_str().unwrap_or("");
// Get current time
let now = chrono::Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
// Create canonical request
let payload_hash = if let Some(b) = body {
let mut hasher = Sha256::new();
hasher.update(b.as_bytes());
hex::encode(hasher.finalize())
} else {
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()
// empty string hash
};
let canonical_uri = parsed_url.path();
let canonical_querystring = parsed_url.query().unwrap_or("");
let mut signed_headers = "content-type;host;x-amz-date".to_string();
let mut canonical_headers = format!(
"content-type:application/json\nhost:{}\nx-amz-date:{}\n",
host, amz_date
);
if let Some(ref token) = session_token {
signed_headers = "content-type;host;x-amz-date;x-amz-security-token".to_string();
canonical_headers = format!(
"content-type:application/json\nhost:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
host, amz_date, token
);
}
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
method,
canonical_uri,
canonical_querystring,
canonical_headers,
signed_headers,
payload_hash
);
// Create string to sign
let algorithm = "AWS4-HMAC-SHA256";
let credential_scope = format!("{}/{}/bedrock-agentcore/aws4_request", date_stamp, region);
let mut hasher = Sha256::new();
hasher.update(canonical_request.as_bytes());
let canonical_request_hash = hex::encode(hasher.finalize());
let string_to_sign = format!(
"{}\n{}\n{}\n{}",
algorithm, amz_date, credential_scope, canonical_request_hash
);
// Calculate signature
type HmacSha256 = Hmac<Sha256>;
let k_date = HmacSha256::new_from_slice(format!("AWS4{}", secret_key).as_bytes())
.unwrap()
.chain_update(date_stamp.as_bytes())
.finalize()
.into_bytes();
let k_region = HmacSha256::new_from_slice(&k_date)
.unwrap()
.chain_update(region.as_bytes())
.finalize()
.into_bytes();
let k_service = HmacSha256::new_from_slice(&k_region)
.unwrap()
.chain_update(b"bedrock-agentcore")
.finalize()
.into_bytes();
let k_signing = HmacSha256::new_from_slice(&k_service)
.unwrap()
.chain_update(b"aws4_request")
.finalize()
.into_bytes();
let signature = hex::encode(
HmacSha256::new_from_slice(&k_signing)
.unwrap()
.chain_update(string_to_sign.as_bytes())
.finalize()
.into_bytes(),
);
// Build authorization header
let authorization = format!(
"{} Credential={}/{}, SignedHeaders={}, Signature={}",
algorithm, access_key, credential_scope, signed_headers, signature
);
let mut headers = vec![
("host".to_string(), host.to_string()),
("content-type".to_string(), "application/json".to_string()),
("x-amz-date".to_string(), amz_date),
("authorization".to_string(), authorization),
];
if let Some(token) = session_token {
headers.push(("x-amz-security-token".to_string(), token));
}
Ok(headers)
}
pub async fn close_session(session_id: &str) -> Result<(), String> {
let info = get_agentcore_info();
let (region, browser_id) = match &info {
Some(i) => (i.region.clone(), i.browser_identifier.clone()),
None => {
let region = env::var("AGENTCORE_REGION")
.or_else(|_| env::var("AWS_REGION"))
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string());
let browser_id = env::var("AGENTCORE_BROWSER_ID")
.unwrap_or_else(|_| "aws.browser.v1".to_string());
(region, browser_id)
}
};
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
let path = format!(
"/browsers/{}/sessions/stop",
urlencoding::encode(&browser_id)
);
let url = format!("https://{}{}", host, path);
let body = serde_json::to_string(&json!({ "sessionId": session_id }))
.map_err(|e| format!("Failed to serialize close request: {}", e))?;
let signed_headers = sign_request("PUT", &url, &region, Some(&body)).await?;
let client = reqwest::Client::new();
let mut req = client.put(&url).body(body);
for (key, value) in &signed_headers {
req = req.header(key.as_str(), value.as_str());
}
let _ = req.send().await;
Ok(())
}
}
pub use agentcore::{get_agentcore_info, take_agentcore_ws_headers};
async fn connect_agentcore() -> Result<(String, Option<ProviderSession>), String> {
agentcore::connect().await
}
async fn close_agentcore_session(session_id: &str) -> Result<(), String> {
agentcore::close_session(session_id).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connect_provider_unknown() {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(connect_provider("unknown-provider"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown provider"));
}
#[test]
fn test_agentcore_env_defaults() {
// Test that default values are used when env vars not set
std::env::remove_var("AGENTCORE_REGION");
std::env::remove_var("AGENTCORE_BROWSER_ID");
std::env::remove_var("AGENTCORE_SESSION_TIMEOUT");
// These would be used in connect() - just verify they don't panic
let region = std::env::var("AGENTCORE_REGION")
.or_else(|_| std::env::var("AWS_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string());
assert_eq!(region, "us-east-1");
let browser_id =
std::env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
assert_eq!(browser_id, "aws.browser.v1");
}
#[test]
fn test_agentcore_session_info_storage() {
let info = agentcore::AgentCoreSessionInfo {
session_id: "test-session".to_string(),
browser_identifier: "aws.browser.v1".to_string(),
region: "us-east-1".to_string(),
live_view_url: "https://example.com".to_string(),
};
agentcore::set_agentcore_info(info);
let retrieved = get_agentcore_info();
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.session_id, "test-session");
assert_eq!(retrieved.region, "us-east-1");
}
#[test]
fn test_agentcore_ws_headers_storage() {
let headers = vec![
(
"Authorization".to_string(),
"AWS4-HMAC-SHA256...".to_string(),
),
("X-Amz-Date".to_string(), "20260304T180000Z".to_string()),
];
agentcore::set_agentcore_ws_headers(headers);
let taken = take_agentcore_ws_headers();
assert!(taken.is_some());
assert_eq!(taken.unwrap().len(), 2);
// Should be None after take
let taken_again = take_agentcore_ws_headers();
assert!(taken_again.is_none());
}
}
+323
View File
@@ -0,0 +1,323 @@
use serde_json::{json, Value};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::sync::oneshot;
use super::cdp::client::CdpClient;
use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult};
const CAPTURE_INTERVAL_MS: u64 = 100;
const CAPTURE_FPS: u32 = 10;
pub struct RecordingState {
pub active: bool,
pub output_path: String,
pub frame_count: u64,
pub capture_task: Option<tokio::task::JoinHandle<Result<(), String>>>,
pub shared_frame_count: Option<Arc<AtomicU64>>,
pub cancel_tx: Option<oneshot::Sender<()>>,
}
impl RecordingState {
pub fn new() -> Self {
Self {
active: false,
output_path: String::new(),
frame_count: 0,
capture_task: None,
shared_frame_count: None,
cancel_tx: None,
}
}
}
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
if state.active {
return Err("Recording already active".to_string());
}
state.active = true;
state.output_path = path.to_string();
state.frame_count = 0;
Ok(json!({ "started": true, "path": path }))
}
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
if !state.active {
return Err("No recording in progress".to_string());
}
state.active = false;
if state.frame_count == 0 {
return Err("No frames captured".to_string());
}
Ok(json!({ "path": &state.output_path, "frames": state.frame_count }))
}
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
let previous = if state.active {
let stop_result = recording_stop(state);
stop_result
.ok()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
} else {
None
};
recording_start(state, path)?;
Ok(json!({
"restarted": true,
"previousPath": previous,
"path": path,
}))
}
fn build_ffmpeg_command(output_path: &str) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("ffmpeg");
cmd.args(["-y"])
.args(["-avioflags", "direct"])
.args([
"-fpsprobesize",
"0",
"-probesize",
"32",
"-analyzeduration",
"0",
])
.args([
"-f",
"image2pipe",
"-c:v",
"mjpeg",
"-framerate",
&CAPTURE_FPS.to_string(),
"-i",
"pipe:0",
])
.args(["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]);
if output_path.ends_with(".webm") {
cmd.args(["-c:v", "libvpx", "-crf", "30", "-b:v", "1M"]);
} else {
cmd.args(["-c:v", "libx264", "-preset", "ultrafast"]);
}
cmd.args(["-pix_fmt", "yuv420p", "-threads", "1"])
.arg(output_path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
cmd
}
/// Spawn a background task that captures screenshots at a fixed interval
/// and pipes them to ffmpeg in real-time.
pub fn spawn_recording_task(
client: Arc<CdpClient>,
session_id: String,
output_path: String,
shared_count: Arc<AtomicU64>,
cancel_rx: oneshot::Receiver<()>,
) -> tokio::task::JoinHandle<Result<(), String>> {
tokio::spawn(async move {
let mut cancel_rx = std::pin::pin!(cancel_rx);
let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| {
format!(
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
e
)
})?;
let mut stdin = ffmpeg
.stdin
.take()
.ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?;
let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let params = CaptureScreenshotParams {
format: Some("jpeg".to_string()),
quality: Some(80),
clip: None,
from_surface: Some(true),
capture_beyond_viewport: None,
};
loop {
tokio::select! {
_ = &mut cancel_rx => break,
_ = interval.tick() => {}
}
let result: Result<CaptureScreenshotResult, _> = client
.send_command_typed("Page.captureScreenshot", &params, Some(&session_id))
.await;
let screenshot = match result {
Ok(s) => s,
Err(e) => {
if e.contains("Target closed") || e.contains("not found") {
break;
}
continue;
}
};
let bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&screenshot.data,
) {
Ok(b) => b,
Err(_) => continue,
};
if stdin.write_all(&bytes).await.is_err() {
break;
}
shared_count.fetch_add(1, Ordering::Relaxed);
}
drop(stdin);
let output = ffmpeg
.wait_with_output()
.await
.map_err(|e| format!("ffmpeg wait failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"ffmpeg failed: {}",
stderr.chars().take(300).collect::<String>()
));
}
Ok(())
})
}
pub async fn stop_recording_task(state: &mut RecordingState) -> Result<(), String> {
if let Some(tx) = state.cancel_tx.take() {
let _ = tx.send(());
}
let counter = state.shared_frame_count.take();
let handle = state.capture_task.take();
let result = if let Some(h) = handle {
match h.await {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e),
Err(e) => Err(format!("Recording task panicked: {}", e)),
}
} else {
Ok(())
};
if let Some(c) = counter {
state.frame_count = c.load(Ordering::Relaxed);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recording_state_new() {
let state = RecordingState::new();
assert!(!state.active);
assert!(state.output_path.is_empty());
assert_eq!(state.frame_count, 0);
}
#[test]
fn test_recording_start_sets_active() {
let mut state = RecordingState::new();
let result = recording_start(&mut state, "/tmp/test.mp4");
assert!(result.is_ok());
assert!(state.active);
assert_eq!(state.output_path, "/tmp/test.mp4");
assert_eq!(state.frame_count, 0);
}
#[test]
fn test_recording_start_while_active() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
let result = recording_start(&mut state, "/tmp/test2.mp4");
assert!(result.is_err());
assert!(result.unwrap_err().contains("already active"));
}
#[test]
fn test_recording_stop_not_active() {
let mut state = RecordingState::new();
let result = recording_stop(&mut state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No recording"));
}
#[test]
fn test_recording_stop_no_frames() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test.mp4").unwrap();
let result = recording_stop(&mut state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No frames"));
assert!(!state.active);
}
#[test]
fn test_recording_restart_while_inactive() {
let mut state = RecordingState::new();
let result = recording_restart(&mut state, "/tmp/new.webm");
assert!(result.is_ok());
assert!(state.active);
assert_eq!(state.output_path, "/tmp/new.webm");
}
#[test]
fn test_recording_restart_while_active() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/old.webm").unwrap();
state.frame_count = 10;
let result = recording_restart(&mut state, "/tmp/new.webm").unwrap();
assert!(state.active);
assert_eq!(state.output_path, "/tmp/new.webm");
assert_eq!(state.frame_count, 0);
assert_eq!(result["previousPath"], "/tmp/old.webm");
}
#[test]
fn test_build_ffmpeg_command_webm() {
let cmd = build_ffmpeg_command("/tmp/out.webm");
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
assert!(args_str.contains(&"libvpx"));
assert!(args_str.contains(&"/tmp/out.webm"));
}
#[test]
fn test_build_ffmpeg_command_mp4() {
let cmd = build_ffmpeg_command("/tmp/out.mp4");
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
assert!(args_str.contains(&"libx264"));
assert!(args_str.contains(&"/tmp/out.mp4"));
}
}
+691
View File
@@ -0,0 +1,691 @@
use serde::Serialize;
use serde_json::Value;
use std::path::PathBuf;
use std::collections::HashMap;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::RefMap;
const ANNOTATION_OVERLAY_ID: &str = "__agent_browser_annotations__";
#[derive(Debug, Clone)]
struct Rect {
x: f64,
y: f64,
width: f64,
height: f64,
}
#[derive(Debug, Clone)]
struct RawAnnotation {
ref_id: String,
number: u64,
role: String,
name: Option<String>,
rect: Rect,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnnotationBox {
pub x: i64,
pub y: i64,
pub width: i64,
pub height: i64,
}
#[derive(Debug, Clone)]
pub struct ScreenshotAnnotation {
pub ref_id: String,
pub number: u64,
pub role: String,
pub name: Option<String>,
pub box_: AnnotationBox,
}
#[derive(Debug, Clone)]
pub struct ScreenshotResult {
pub path: String,
pub base64: String,
pub annotations: Vec<ScreenshotAnnotation>,
}
#[derive(Debug, Clone)]
pub struct ScreenshotOptions {
pub selector: Option<String>,
pub path: Option<String>,
pub full_page: bool,
pub format: String,
pub quality: Option<i32>,
pub annotate: bool,
pub output_dir: Option<String>,
}
impl Default for ScreenshotOptions {
fn default() -> Self {
Self {
selector: None,
path: None,
full_page: false,
format: "png".to_string(),
quality: None,
annotate: false,
output_dir: None,
}
}
}
impl Serialize for ScreenshotAnnotation {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
state.serialize_field("ref", &self.ref_id)?;
state.serialize_field("number", &self.number)?;
state.serialize_field("role", &self.role)?;
if let Some(name) = &self.name {
state.serialize_field("name", name)?;
}
state.serialize_field("box", &self.box_)?;
state.end()
}
}
/// Captures a screenshot via CDP and optionally overlays numbered annotations
/// that mirror the Node.js screenshot `annotate` mode.
pub async fn take_screenshot(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
options: &ScreenshotOptions,
iframe_sessions: &HashMap<String, String>,
) -> Result<ScreenshotResult, String> {
let target_rect = if options.annotate {
match options.selector.as_deref() {
Some(selector) => {
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
.await?
}
None => None,
}
} else {
None
};
let raw_annotations = if options.annotate {
collect_annotations(client, session_id, ref_map).await?
} else {
Vec::new()
};
let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
let overlay_injected = if options.annotate && !overlay_items.is_empty() {
inject_annotation_overlay(client, session_id, &overlay_items).await?;
true
} else {
false
};
let base64 =
capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
if overlay_injected {
let _ = remove_annotation_overlay(client, session_id).await;
}
let base64 = base64?;
let annotations = if options.annotate {
let scroll = if options.full_page {
Some(get_scroll_offsets(client, session_id).await?)
} else {
None
};
project_annotations(&overlay_items, target_rect.as_ref(), scroll)
} else {
Vec::new()
};
let ext = if options.format == "jpeg" {
"jpg"
} else {
"png"
};
let path = save_screenshot(
&base64,
options.path.as_deref(),
ext,
options.output_dir.as_deref(),
)?;
Ok(ScreenshotResult {
path,
base64,
annotations,
})
}
async fn capture_screenshot_base64(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
options: &ScreenshotOptions,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let mut params = CaptureScreenshotParams {
format: Some(options.format.clone()),
quality: if options.format == "jpeg" {
options.quality.or(Some(80))
} else {
None
},
clip: None,
from_surface: Some(true),
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
};
if options.full_page {
let metrics: Value = client
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
.await?;
let content_size = metrics
.get("contentSize")
.or_else(|| metrics.get("cssContentSize"));
if let Some(size) = content_size {
let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
params.clip = Some(Viewport {
x: 0.0,
y: 0.0,
width,
height,
scale: 1.0,
});
}
} else if let Some(ref selector) = options.selector {
if let Some(rect) =
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
{
params.clip = Some(Viewport {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
scale: 1.0,
});
}
}
let result: CaptureScreenshotResult = client
.send_command_typed("Page.captureScreenshot", &params, Some(session_id))
.await?;
Ok(result.data)
}
async fn collect_annotations(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
) -> Result<Vec<RawAnnotation>, String> {
let entries = ref_map.entries_sorted();
if entries.is_empty() {
return Ok(Vec::new());
}
// Collect entries that have backend_node_ids for batch resolution.
let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
.iter()
.filter_map(|(ref_id, entry)| {
entry
.backend_node_id
.map(|bid| (ref_id.clone(), entry.clone(), bid))
})
.collect();
if with_backend_ids.is_empty() {
return Ok(Vec::new());
}
// Batch-resolve all backend_node_ids to object IDs using concurrent CDP calls.
let resolve_futures: Vec<_> = with_backend_ids
.iter()
.map(|(_, _, backend_node_id)| {
client.send_command(
"DOM.resolveNode",
Some(serde_json::json!({
"backendNodeId": backend_node_id,
"objectGroup": "agent-browser-annotate"
})),
Some(session_id),
)
})
.collect();
let resolve_results = futures_util::future::join_all(resolve_futures).await;
// Collect resolved object IDs paired with their ref info.
let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
for (i, result) in resolve_results.into_iter().enumerate() {
if let Ok(val) = result {
if let Some(oid) = val
.get("object")
.and_then(|o| o.get("objectId"))
.and_then(|v| v.as_str())
{
let (ref_id, entry, _) = &with_backend_ids[i];
resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
}
}
}
if resolved.is_empty() {
return Ok(Vec::new());
}
// Batch-get bounding rects for all resolved elements using concurrent CDP calls.
let rect_futures: Vec<_> = resolved
.iter()
.map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
.collect();
let rect_results = futures_util::future::join_all(rect_futures).await;
let mut annotations = Vec::new();
for (i, rect_result) in rect_results.into_iter().enumerate() {
let rect = match rect_result {
Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
_ => continue,
};
let (ref_id, entry, _) = &resolved[i];
let number = ref_id
.strip_prefix('e')
.and_then(|n| n.parse::<u64>().ok())
.unwrap_or(0);
annotations.push(RawAnnotation {
ref_id: ref_id.clone(),
number,
role: entry.role.clone(),
name: (!entry.name.is_empty()).then_some(entry.name.clone()),
rect,
});
}
Ok(annotations)
}
async fn get_rect_for_selector(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<Option<Rect>, String> {
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
client,
session_id,
ref_map,
selector,
iframe_sessions,
)
.await?;
get_rect_for_object(client, &effective_session_id, &object_id).await
}
async fn get_rect_for_object(
client: &CdpClient,
session_id: &str,
object_id: &str,
) -> Result<Option<Rect>, String> {
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const rect = this.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}"#
.to_string(),
object_id: Some(object_id.to_string()),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result.result.value.as_ref().and_then(parse_rect))
}
fn parse_rect(value: &Value) -> Option<Rect> {
Some(Rect {
x: value.get("x")?.as_f64()?,
y: value.get("y")?.as_f64()?,
width: value.get("width")?.as_f64()?,
height: value.get("height")?.as_f64()?,
})
}
fn filter_annotations(
annotations: Vec<RawAnnotation>,
target_rect: Option<&Rect>,
) -> Vec<RawAnnotation> {
let mut items = annotations
.into_iter()
.filter(|annotation| match target_rect {
Some(target) => overlaps(&annotation.rect, target),
None => true,
})
.collect::<Vec<_>>();
items.sort_by_key(|annotation| annotation.number);
items
}
fn overlaps(left: &Rect, right: &Rect) -> bool {
let left_x2 = left.x + left.width;
let left_y2 = left.y + left.height;
let right_x2 = right.x + right.width;
let right_y2 = right.y + right.height;
left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
}
async fn inject_annotation_overlay(
client: &CdpClient,
session_id: &str,
annotations: &[RawAnnotation],
) -> Result<(), String> {
let overlay_data = annotations
.iter()
.map(|annotation| {
serde_json::json!({
"number": annotation.number,
"x": round(annotation.rect.x),
"y": round(annotation.rect.y),
"width": round(annotation.rect.width),
"height": round(annotation.rect.height),
})
})
.collect::<Vec<_>>();
let expression = format!(
r#"(() => {{
var items = {items};
var id = {overlay_id};
var existing = document.getElementById(id);
if (existing) existing.remove();
var sx = window.scrollX || 0;
var sy = window.scrollY || 0;
var c = document.createElement('div');
c.id = id;
c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
for (var i = 0; i < items.length; i++) {{
var it = items[i];
var dx = it.x + sx;
var dy = it.y + sy;
var b = document.createElement('div');
b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
var l = document.createElement('div');
l.textContent = String(it.number);
var labelTop = dy < 14 ? '2px' : '-14px';
l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
b.appendChild(l);
c.appendChild(b);
}}
document.documentElement.appendChild(c);
return true;
}})()"#,
items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
overlay_id =
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
);
let _: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
let expression = format!(
r#"(() => {{
var el = document.getElementById({overlay_id});
if (el) el.remove();
return true;
}})()"#,
overlay_id =
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
);
let _: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let value = result.result.value.unwrap_or(Value::Null);
let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
Ok((x, y))
}
fn project_annotations(
annotations: &[RawAnnotation],
target_rect: Option<&Rect>,
scroll: Option<(f64, f64)>,
) -> Vec<ScreenshotAnnotation> {
annotations
.iter()
.map(|annotation| {
let rect = if let Some(target) = target_rect {
Rect {
x: annotation.rect.x - target.x,
y: annotation.rect.y - target.y,
width: annotation.rect.width,
height: annotation.rect.height,
}
} else if let Some((scroll_x, scroll_y)) = scroll {
Rect {
x: annotation.rect.x + scroll_x,
y: annotation.rect.y + scroll_y,
width: annotation.rect.width,
height: annotation.rect.height,
}
} else {
annotation.rect.clone()
};
ScreenshotAnnotation {
ref_id: annotation.ref_id.clone(),
number: annotation.number,
role: annotation.role.clone(),
name: annotation.name.clone(),
box_: AnnotationBox {
x: round(rect.x),
y: round(rect.y),
width: round(rect.width),
height: round(rect.height),
},
}
})
.collect()
}
fn save_screenshot(
base64_data: &str,
explicit_path: Option<&str>,
ext: &str,
output_dir: Option<&str>,
) -> Result<String, String> {
let save_path = match explicit_path {
Some(path) => path.to_string(),
None => {
let dir = match output_dir {
Some(d) => PathBuf::from(d),
None => get_screenshot_dir(),
};
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let name = format!("screenshot-{}.{}", timestamp, ext);
dir.join(name).to_string_lossy().to_string()
}
};
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
std::fs::write(&save_path, &bytes)
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
Ok(save_path)
}
fn round(value: f64) -> i64 {
value.round() as i64
}
fn get_screenshot_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("screenshots")
} else {
std::env::temp_dir()
.join("agent-browser")
.join("screenshots")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_annotations_to_target_overlap() {
let annotations = vec![
RawAnnotation {
ref_id: "e1".to_string(),
number: 1,
role: "button".to_string(),
name: Some("Inside".to_string()),
rect: Rect {
x: 10.0,
y: 10.0,
width: 50.0,
height: 20.0,
},
},
RawAnnotation {
ref_id: "e2".to_string(),
number: 2,
role: "button".to_string(),
name: Some("Outside".to_string()),
rect: Rect {
x: 200.0,
y: 200.0,
width: 40.0,
height: 20.0,
},
},
];
let target = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
};
let filtered = filter_annotations(annotations, Some(&target));
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].ref_id, "e1");
}
#[test]
fn projects_selector_annotations_relative_to_target() {
let annotations = vec![RawAnnotation {
ref_id: "e1".to_string(),
number: 1,
role: "button".to_string(),
name: Some("Inside".to_string()),
rect: Rect {
x: 25.0,
y: 35.0,
width: 40.0,
height: 20.0,
},
}];
let target = Rect {
x: 10.0,
y: 15.0,
width: 100.0,
height: 100.0,
};
let projected = project_annotations(&annotations, Some(&target), None);
assert_eq!(projected[0].box_.x, 15);
assert_eq!(projected[0].box_.y, 20);
}
#[test]
fn projects_full_page_annotations_to_document_space() {
let annotations = vec![RawAnnotation {
ref_id: "e1".to_string(),
number: 1,
role: "button".to_string(),
name: Some("Bottom".to_string()),
rect: Rect {
x: 5.0,
y: 12.0,
width: 40.0,
height: 20.0,
},
}];
let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
assert_eq!(projected[0].box_.x, 15);
assert_eq!(projected[0].box_.y, 1012);
}
}
File diff suppressed because it is too large Load Diff
+887
View File
@@ -0,0 +1,887 @@
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use super::cdp::client::CdpClient;
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
CreateTargetResult, EvaluateParams,
};
use super::cookies::{self, Cookie};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageState {
pub cookies: Vec<Cookie>,
pub origins: Vec<OriginStorage>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OriginStorage {
pub origin: String,
pub local_storage: Vec<StorageEntry>,
#[serde(default)]
pub session_storage: Vec<StorageEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageEntry {
pub name: String,
pub value: String,
}
fn collect_frame_origins(tree: &Value, origins: &mut HashSet<String>) {
if let Some(frame) = tree.get("frame") {
if let Some(url_str) = frame.get("url").and_then(|v| v.as_str()) {
if let Ok(parsed) = url::Url::parse(url_str) {
let origin = parsed.origin().ascii_serialization();
if origin != "null" && !origin.is_empty() {
origins.insert(origin);
}
}
}
}
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
for child in children {
collect_frame_origins(child, origins);
}
}
}
/// Parse the JS-evaluated origin storage data into an OriginStorage struct.
fn parse_origin_storage(data: &Value) -> Option<OriginStorage> {
if !data.is_object() {
return None;
}
let origin = data
.get("origin")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if origin.is_empty() || origin == "null" {
return None;
}
let local_storage: Vec<StorageEntry> = data
.get("localStorage")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let session_storage: Vec<StorageEntry> = data
.get("sessionStorage")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
Some(OriginStorage {
origin,
local_storage,
session_storage,
})
}
/// Evaluate the storage-collection JS snippet and parse the result.
async fn eval_origin_storage(
client: &CdpClient,
session_id: &str,
origin_js: &str,
) -> Option<OriginStorage> {
let result = client
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: origin_js.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await
.ok()?;
let data = result.result.value.unwrap_or(Value::Null);
parse_origin_storage(&data)
}
/// Create a temporary CDP target, navigate it to each origin to collect localStorage,
/// then close it. Uses Fetch interception to serve blank HTML instead of making real
/// network requests.
async fn collect_storage_via_temp_target(
client: &CdpClient,
origins: &[String],
origin_js: &str,
) -> Result<Vec<OriginStorage>, String> {
let create_result: CreateTargetResult = client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
},
None,
)
.await?;
let target_id = create_result.target_id;
// Ensure the target is closed even if attach or later steps fail
let result = collect_storage_in_target(client, &target_id, origins, origin_js).await;
let _ = client
.send_command_typed::<_, Value>(
"Target.closeTarget",
&CloseTargetParams { target_id },
None,
)
.await;
result
}
async fn collect_storage_in_target(
client: &CdpClient,
target_id: &str,
origins: &[String],
origin_js: &str,
) -> Result<Vec<OriginStorage>, String> {
let attach_result: AttachToTargetResult = client
.send_command_typed(
"Target.attachToTarget",
&AttachToTargetParams {
target_id: target_id.to_string(),
flatten: true,
},
None,
)
.await?;
let temp_session = &attach_result.session_id;
client
.send_command_no_params("Page.enable", Some(temp_session))
.await?;
client
.send_command_no_params("Runtime.enable", Some(temp_session))
.await?;
// Blank HTML response body, pre-encoded to avoid repeated base64 work per request
let blank_html_b64 = base64::engine::general_purpose::STANDARD.encode("<html></html>");
let _ = client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
Some(temp_session),
)
.await;
let mut event_rx = client.subscribe();
let mut results = Vec::new();
for target_origin in origins {
let nav_url = format!("{}/", target_origin.trim_end_matches('/'));
if client
.send_command(
"Page.navigate",
Some(json!({ "url": nav_url })),
Some(temp_session),
)
.await
.is_err()
{
continue;
}
// Fulfill intercepted requests with blank HTML until the page loads
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
let mut page_loaded = false;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await {
Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => {
if evt.method == "Fetch.requestPaused" {
if let Some(request_id) =
evt.params.get("requestId").and_then(|v| v.as_str())
{
let _ = client
.send_command(
"Fetch.fulfillRequest",
Some(json!({
"requestId": request_id,
"responseCode": 200,
"responseHeaders": [
{ "name": "Content-Type", "value": "text/html" }
],
"body": &blank_html_b64
})),
Some(temp_session),
)
.await;
}
} else if evt.method == "Page.loadEventFired" {
page_loaded = true;
break;
}
}
Ok(Ok(_)) => continue, // event for a different session
Ok(Err(_)) => continue, // lagged or closed — retry within deadline
Err(_) => break, // outer timeout elapsed
}
}
if !page_loaded {
continue;
}
if let Some(storage) = eval_origin_storage(client, temp_session, origin_js).await {
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
results.push(storage);
}
}
}
Ok(results)
}
pub async fn save_state(
client: &CdpClient,
session_id: &str,
path: Option<&str>,
session_name: Option<&str>,
session_id_str: &str,
visited_origins: &HashSet<String>,
) -> Result<String, String> {
let cookies = cookies::get_all_cookies(client, session_id).await?;
let origin_js = r#"(() => {
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
result.localStorage.push({ name: key, value: localStorage.getItem(key) });
}
} catch(e) {}
try {
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
}
} catch(e) {}
return result;
})()"#;
// Merge visited origins with current frame tree origins
let mut all_origins = visited_origins.clone();
if let Ok(tree_result) = client
.send_command_no_params("Page.getFrameTree", Some(session_id))
.await
{
if let Some(tree) = tree_result.get("frameTree") {
collect_frame_origins(tree, &mut all_origins);
}
}
// 1. Collect localStorage from the current page
let mut origins = Vec::new();
let mut current_origin = String::new();
if let Some(storage) = eval_origin_storage(client, session_id, origin_js).await {
current_origin = storage.origin.clone();
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
origins.push(storage);
}
}
// 2. Collect localStorage from remaining origins via a disposable temp target
all_origins.remove(&current_origin);
if !all_origins.is_empty() {
let remaining: Vec<String> = all_origins.into_iter().collect();
if let Ok(temp_origins) =
collect_storage_via_temp_target(client, &remaining, origin_js).await
{
origins.extend(temp_origins);
}
}
let state = StorageState { cookies, origins };
let json_str = serde_json::to_string_pretty(&state)
.map_err(|e| format!("Failed to serialize state: {}", e))?;
let mut save_path = match path {
Some(p) => p.to_string(),
None => {
let dir = get_sessions_dir();
let _ = fs::create_dir_all(&dir);
let name = session_name.unwrap_or("default");
dir.join(format!("{}-{}.json", name, session_id_str))
.to_string_lossy()
.to_string()
}
};
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
save_path.push_str(".enc");
fs::write(&save_path, &encrypted)
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
} else {
fs::write(&save_path, &json_str)
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
}
Ok(save_path)
}
pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
let json_str = if path.ends_with(".enc") {
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
})?;
let data =
fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
let decrypted = decrypt_data(&data, &key)?;
String::from_utf8(decrypted)
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
} else {
match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
let enc_path = format!("{}.enc", path);
if let Ok(data) = fs::read(&enc_path) {
let decrypted = decrypt_data(&data, &key)?;
String::from_utf8(decrypted)
.map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?
} else {
return Err(format!("Failed to read state from {}: {}", path, e));
}
} else {
return Err(format!("Failed to read state from {}: {}", path, e));
}
}
}
};
let state: StorageState =
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
// Load cookies
if !state.cookies.is_empty() {
let cookie_values: Vec<Value> = state
.cookies
.iter()
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
.collect();
cookies::set_cookies(client, session_id, cookie_values, None).await?;
}
// Load storage per origin
for origin in &state.origins {
if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
continue;
}
// Navigate to origin to set storage
let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
client
.send_command(
"Page.navigate",
Some(json!({ "url": navigate_url })),
Some(session_id),
)
.await?;
// Brief wait for navigation
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
for entry in &origin.local_storage {
let js = format!(
"localStorage.setItem({}, {})",
serde_json::to_string(&entry.name).unwrap_or_default(),
serde_json::to_string(&entry.value).unwrap_or_default(),
);
let _ = client
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await;
}
for entry in &origin.session_storage {
let js = format!(
"sessionStorage.setItem({}, {})",
serde_json::to_string(&entry.name).unwrap_or_default(),
serde_json::to_string(&entry.value).unwrap_or_default(),
);
let _ = client
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await;
}
}
Ok(())
}
fn is_state_file(path: &std::path::Path) -> bool {
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
fname.ends_with(".json") || fname.ends_with(".json.enc")
}
fn is_encrypted_state(path: &std::path::Path) -> bool {
path.to_string_lossy().ends_with(".json.enc")
}
pub fn state_list() -> Result<Value, String> {
let dir = get_sessions_dir();
if !dir.exists() {
return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
}
let mut files = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if is_state_file(&path) {
let metadata = fs::metadata(&path).ok();
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
let modified = metadata
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let encrypted = is_encrypted_state(&path);
files.push(json!({
"filename": filename,
"path": path.to_string_lossy(),
"size": size,
"modified": modified,
"encrypted": encrypted,
}));
}
}
Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
}
pub fn state_show(path: &str) -> Result<Value, String> {
let encrypted = path.ends_with(".enc");
let json_str = if encrypted {
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
})?;
let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
let decrypted = decrypt_data(&data, &key)?;
String::from_utf8(decrypted)
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
} else {
fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
};
let state: StorageState =
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
let metadata = fs::metadata(path).ok();
let filename = std::path::Path::new(path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Ok(json!({
"filename": filename,
"path": path,
"size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
"modified": metadata.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0),
"encrypted": encrypted,
"summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
"state": state,
}))
}
pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
if let Some(p) = path {
fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
return Ok(json!({ "deleted": p }));
}
let dir = get_sessions_dir();
if !dir.exists() {
return Ok(json!({ "deleted": 0 }));
}
let mut count = 0;
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if is_state_file(&path) {
let _ = fs::remove_file(&path);
count += 1;
}
}
}
Ok(json!({ "deleted": count }))
}
pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
let dir = get_sessions_dir();
if !dir.exists() {
return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
}
let now = std::time::SystemTime::now();
let max_age = std::time::Duration::from_secs(max_age_days * 86400);
let mut deleted = 0;
let mut kept = 0;
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if !is_state_file(&path) {
continue;
}
if let Ok(metadata) = fs::metadata(&path) {
if let Ok(modified) = metadata.modified() {
if let Ok(age) = now.duration_since(modified) {
if age > max_age {
let _ = fs::remove_file(&path);
deleted += 1;
continue;
}
}
}
}
kept += 1;
}
}
Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
}
pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
let old = PathBuf::from(old_path);
if !old.exists() {
return Err(format!("State file not found: {}", old_path));
}
let fallback = PathBuf::from(".");
let dir = old.parent().unwrap_or(&fallback);
let new_path = dir.join(format!("{}.json", new_name));
fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
Ok(json!({
"renamed": true,
"from": old_path,
"to": new_path.to_string_lossy(),
}))
}
fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
let mut hasher = Sha256::new();
hasher.update(key_str.as_bytes());
let key_bytes = hasher.finalize();
let cipher =
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
let mut nonce = [0u8; 12];
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
let ciphertext = cipher
.encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce);
result.extend_from_slice(&ciphertext);
Ok(result)
}
fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
if data.len() < 13 {
return Err("Ciphertext too short".to_string());
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let mut hasher = Sha256::new();
hasher.update(key_str.as_bytes());
let key_bytes = hasher.finalize();
let cipher =
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))?;
Ok(plaintext)
}
pub fn find_auto_state_file(session_name: &str) -> Option<String> {
let dir = get_sessions_dir();
if !dir.exists() {
return None;
}
let prefix = format!("{}-", session_name);
let mut best_path: Option<(String, std::time::SystemTime)> = None;
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let is_match = fname.starts_with(&prefix)
&& (fname.ends_with(".json") || fname.ends_with(".json.enc"));
if !is_match {
continue;
}
let modified = fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
best_path = Some((path.to_string_lossy().to_string(), modified));
}
}
}
best_path.map(|(p, _)| p)
}
/// Dispatch a state management command from its JSON payload.
/// Returns `Some(result)` for recognised state_* actions, `None` otherwise.
pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
let action = cmd.get("action").and_then(|v| v.as_str())?;
match action {
"state_list" => Some(state_list()),
"state_show" => Some(
cmd.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'path' parameter".to_string())
.and_then(state_show),
),
"state_clear" => {
let path = cmd.get("path").and_then(|v| v.as_str());
Some(state_clear(path))
}
"state_clean" => {
let days = cmd.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
Some(state_clean(days))
}
"state_rename" => Some(
cmd.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'path' parameter".to_string())
.and_then(|path| {
cmd.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'name' parameter".to_string())
.and_then(|name| state_rename(path, name))
}),
),
_ => None,
}
}
pub fn get_sessions_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("sessions")
} else {
std::env::temp_dir().join("agent-browser").join("sessions")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_state_serialization() {
let state = StorageState {
cookies: vec![Cookie {
name: "session".to_string(),
value: "abc123".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 0.0,
size: 0,
http_only: true,
secure: false,
session: true,
same_site: Some("Lax".to_string()),
}],
origins: vec![OriginStorage {
origin: "https://example.com".to_string(),
local_storage: vec![StorageEntry {
name: "key".to_string(),
value: "val".to_string(),
}],
session_storage: vec![],
}],
};
let json = serde_json::to_string_pretty(&state).unwrap();
let parsed: StorageState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.cookies.len(), 1);
assert_eq!(parsed.cookies[0].name, "session");
assert_eq!(parsed.origins.len(), 1);
assert_eq!(parsed.origins[0].local_storage.len(), 1);
}
#[test]
fn test_storage_state_empty() {
let state = StorageState {
cookies: vec![],
origins: vec![],
};
let json = serde_json::to_string(&state).unwrap();
let parsed: StorageState = serde_json::from_str(&json).unwrap();
assert!(parsed.cookies.is_empty());
assert!(parsed.origins.is_empty());
}
#[test]
fn test_state_show_nonexistent_file() {
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
assert!(result.is_err());
}
#[test]
fn test_state_clear_nonexistent_file() {
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
assert!(result.is_err());
}
#[test]
fn test_state_rename_nonexistent() {
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_state_list_returns_json() {
let result = state_list().unwrap();
assert!(result.get("files").is_some());
assert!(result.get("directory").is_some());
}
#[test]
fn test_sessions_dir_path() {
let dir = get_sessions_dir();
assert!(dir.to_string_lossy().contains("sessions"));
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let plain = b"hello world";
let key = "test-secret-key";
let encrypted = encrypt_data(plain, key).unwrap();
assert!(encrypted.len() > 12);
assert_ne!(&encrypted[12..], plain);
let decrypted = decrypt_data(&encrypted, key).unwrap();
assert_eq!(decrypted, plain);
}
#[test]
fn test_decrypt_wrong_key_fails() {
let plain = b"secret data";
let encrypted = encrypt_data(plain, "key1").unwrap();
let result = decrypt_data(&encrypted, "key2");
assert!(result.is_err());
}
#[test]
fn test_cookie_serde_roundtrip() {
let cookie = Cookie {
name: "test".to_string(),
value: "123".to_string(),
domain: ".test.com".to_string(),
path: "/api".to_string(),
expires: 1700000000.0,
size: 7,
http_only: false,
secure: true,
session: false,
same_site: Some("Strict".to_string()),
};
let json = serde_json::to_value(&cookie).unwrap();
assert_eq!(json["name"], "test");
assert_eq!(json["httpOnly"], false);
assert_eq!(json["secure"], true);
assert_eq!(json["sameSite"], "Strict");
}
#[test]
fn test_dispatch_state_command_routes_state_list() {
let cmd = serde_json::json!({ "action": "state_list" });
let result = dispatch_state_command(&cmd);
assert!(result.is_some());
assert!(result.unwrap().is_ok());
}
#[test]
fn test_dispatch_state_command_returns_none_for_unknown() {
let cmd = serde_json::json!({ "action": "navigate" });
assert!(dispatch_state_command(&cmd).is_none());
}
#[test]
fn test_dispatch_state_command_returns_none_for_missing_action() {
let cmd = serde_json::json!({});
assert!(dispatch_state_command(&cmd).is_none());
}
#[test]
fn test_dispatch_state_show_missing_path() {
let cmd = serde_json::json!({ "action": "state_show" });
let result = dispatch_state_command(&cmd).unwrap();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
}
#[test]
fn test_dispatch_state_rename_missing_params() {
let cmd = serde_json::json!({ "action": "state_rename" });
let result = dispatch_state_command(&cmd).unwrap();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
let result = dispatch_state_command(&cmd).unwrap();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
}
}
+94
View File
@@ -0,0 +1,94 @@
use serde_json::{json, Value};
use super::cdp::client::CdpClient;
use super::cdp::types::EvaluateParams;
pub async fn storage_get(
client: &CdpClient,
session_id: &str,
storage_type: &str,
key: Option<&str>,
) -> Result<Value, String> {
let st = storage_js_name(storage_type);
if let Some(k) = key {
let js = format!(
"{}.getItem({})",
st,
serde_json::to_string(k).unwrap_or_default()
);
let result = eval_simple(client, session_id, &js).await?;
Ok(json!({ "key": k, "value": result }))
} else {
let js = format!(
r#"(() => {{
const s = {};
const data = {{}};
for (let i = 0; i < s.length; i++) {{
const key = s.key(i);
data[key] = s.getItem(key);
}}
return data;
}})()"#,
st
);
let result = eval_simple(client, session_id, &js).await?;
Ok(json!({ "data": result }))
}
}
pub async fn storage_set(
client: &CdpClient,
session_id: &str,
storage_type: &str,
key: &str,
value: &str,
) -> Result<(), String> {
let st = storage_js_name(storage_type);
let js = format!(
"{}.setItem({}, {})",
st,
serde_json::to_string(key).unwrap_or_default(),
serde_json::to_string(value).unwrap_or_default(),
);
eval_simple(client, session_id, &js).await?;
Ok(())
}
pub async fn storage_clear(
client: &CdpClient,
session_id: &str,
storage_type: &str,
) -> Result<(), String> {
let st = storage_js_name(storage_type);
let js = format!("{}.clear()", st);
eval_simple(client, session_id, &js).await?;
Ok(())
}
fn storage_js_name(storage_type: &str) -> &str {
match storage_type {
"session" => "sessionStorage",
_ => "localStorage",
}
}
async fn eval_simple(client: &CdpClient, session_id: &str, js: &str) -> Result<Value, String> {
let result: super::cdp::types::EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if let Some(ref details) = result.exception_details {
return Err(format!("Storage error: {}", details.text));
}
Ok(result.result.value.unwrap_or(Value::Null))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Drag Probe</title>
<style>
body {
margin: 0;
font: 14px/1.4 sans-serif;
background: #f4f4f4;
}
#pad {
position: relative;
width: 800px;
height: 500px;
margin: 24px;
border: 1px solid #999;
background: white;
overflow: hidden;
}
#target {
position: absolute;
left: 320px;
top: 40px;
width: 100px;
height: 40px;
background: #e34c26;
color: white;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: grab;
}
#target.dragging {
cursor: grabbing;
background: #0d9488;
}
#log {
margin: 24px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
</head>
<body>
<div id="pad">
<div id="target">drag me</div>
</div>
<pre id="log"></pre>
<script>
const target = document.getElementById("target");
const logEl = document.getElementById("log");
window.__dragProbe = {
dragging: false,
events: [],
finalLeft: 320,
finalTop: 40,
};
let offsetX = 0;
let offsetY = 0;
function pushEvent(event, extra = {}) {
window.__dragProbe.events.push({
type: event.type,
button: event.button,
buttons: event.buttons,
x: event.clientX,
y: event.clientY,
target: event.target.id || event.target.tagName,
...extra,
});
logEl.textContent = JSON.stringify(window.__dragProbe, null, 2);
}
function onPointerLikeStart(event) {
if (event.type === "mousedown") {
const rect = target.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
window.__dragProbe.dragging = true;
target.classList.add("dragging");
event.preventDefault();
}
pushEvent(event, { phase: "start" });
}
target.addEventListener("mousedown", (event) => {
const rect = target.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
window.__dragProbe.dragging = true;
target.classList.add("dragging");
event.preventDefault();
pushEvent(event, { phase: "start" });
});
target.addEventListener("pointerdown", onPointerLikeStart);
document.addEventListener("mousemove", (event) => {
if (window.__dragProbe.dragging) {
const left = event.clientX - offsetX;
const top = event.clientY - offsetY;
target.style.left = `${left}px`;
target.style.top = `${top}px`;
window.__dragProbe.finalLeft = left;
window.__dragProbe.finalTop = top;
}
pushEvent(event);
});
document.addEventListener("pointermove", (event) => {
pushEvent(event);
});
document.addEventListener("mouseup", (event) => {
if (window.__dragProbe.dragging) {
window.__dragProbe.dragging = false;
target.classList.remove("dragging");
}
pushEvent(event, { phase: "end" });
});
document.addEventListener("pointerup", (event) => {
pushEvent(event, { phase: "end" });
});
target.addEventListener("dragstart", (event) => {
pushEvent(event, { phase: "dragstart" });
});
</script>
</body>
</html>
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HTML5 Drag Probe</title>
<style>
body {
margin: 24px;
font: 14px/1.4 sans-serif;
}
#source, #dest {
width: 120px;
height: 80px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #666;
user-select: none;
margin-right: 40px;
}
#source {
background: #f97316;
color: white;
}
#dest {
background: #e5e7eb;
}
pre {
margin-top: 24px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
</head>
<body>
<div id="source" draggable="true">drag source</div>
<div id="dest">drop zone</div>
<pre id="log"></pre>
<script>
const source = document.getElementById("source");
const dest = document.getElementById("dest");
const logEl = document.getElementById("log");
window.__html5DragProbe = { events: [] };
function pushEvent(event, extra = {}) {
window.__html5DragProbe.events.push({
type: event.type,
target: event.target.id || event.target.tagName,
x: event.clientX,
y: event.clientY,
button: event.button,
buttons: event.buttons,
...extra,
});
logEl.textContent = JSON.stringify(window.__html5DragProbe, null, 2);
}
for (const type of ["pointerdown", "mousedown", "dragstart", "drag", "dragend"]) {
source.addEventListener(type, (event) => {
if (type === "dragstart") {
event.dataTransfer.setData("text/plain", "probe");
}
pushEvent(event);
});
}
for (const type of ["pointermove", "mousemove", "dragenter", "dragover", "drop", "pointerup", "mouseup"]) {
document.addEventListener(type, (event) => {
if (type === "dragover") {
event.preventDefault();
}
if (type === "drop") {
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
return;
}
pushEvent(event);
});
}
dest.addEventListener("dragover", (event) => event.preventDefault());
dest.addEventListener("drop", (event) => {
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
});
</script>
</body>
</html>
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pointer Capture Probe</title>
<style>
body {
margin: 24px;
font: 14px/1.4 sans-serif;
}
#crop {
position: relative;
width: 240px;
height: 180px;
border: 2px solid #fff;
outline: 1px solid #555;
background: rgba(0, 0, 0, 0.2);
}
#handle {
position: absolute;
width: 20px;
height: 20px;
top: -16px;
left: -16px;
padding-top: 13px;
padding-left: 13px;
box-sizing: content-box;
background: rgba(255, 0, 0, 0.25);
}
#handle::after {
content: "";
display: block;
width: 20px;
height: 20px;
border-top: 2px solid white;
border-left: 2px solid white;
}
pre {
margin-top: 24px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
</head>
<body>
<div id="crop" aria-label="crop area">
<div id="handle" aria-label="crop handle topLeft" data-anchor="topLeft"></div>
</div>
<pre id="log"></pre>
<script>
const crop = document.getElementById("crop");
const handle = document.getElementById("handle");
const logEl = document.getElementById("log");
const state = {
targetAnchor: null,
dragging: false,
moved: false,
events: [],
};
window.__pointerCaptureProbe = state;
function sync() {
logEl.textContent = JSON.stringify(state, null, 2);
}
function push(event, extra = {}) {
state.events.push({
type: event.type,
target: event.target.id || event.target.tagName,
currentTarget: event.currentTarget.id || event.currentTarget.tagName,
pointerId: event.pointerId,
button: event.button,
buttons: event.buttons,
hasCapture: event.currentTarget.hasPointerCapture?.(event.pointerId) ?? false,
x: event.clientX,
y: event.clientY,
...extra,
});
sync();
}
crop.addEventListener("pointerdown", (event) => {
state.targetAnchor = event.target.getAttribute("data-anchor");
crop.setPointerCapture(event.pointerId);
event.preventDefault();
push(event, { phase: "down", targetAnchor: state.targetAnchor });
});
crop.addEventListener("pointermove", (event) => {
const hasCapture = crop.hasPointerCapture(event.pointerId);
if (hasCapture && state.targetAnchor) {
state.dragging = true;
state.moved = true;
}
push(event, { phase: hasCapture ? "drag" : "hover", targetAnchor: state.targetAnchor });
});
crop.addEventListener("pointerup", (event) => {
const hadCapture = crop.hasPointerCapture(event.pointerId);
state.dragging = false;
push(event, { phase: "up", targetAnchor: state.targetAnchor, hadCapture });
state.targetAnchor = null;
});
handle.addEventListener("pointerdown", (event) => push(event, { listener: "handle" }));
handle.addEventListener("pointermove", (event) => push(event, { listener: "handle" }));
handle.addEventListener("pointerup", (event) => push(event, { listener: "handle" }));
sync();
</script>
</body>
</html>
+373
View File
@@ -0,0 +1,373 @@
use serde_json::{json, Value};
use std::path::PathBuf;
use super::cdp::client::CdpClient;
const MAX_PROFILE_EVENTS: usize = 5_000_000;
const DEFAULT_PROFILER_CATEGORIES: &[&str] = &[
"devtools.timeline",
"disabled-by-default-devtools.timeline",
"disabled-by-default-devtools.timeline.frame",
"disabled-by-default-devtools.timeline.stack",
"v8.execute",
"disabled-by-default-v8.cpu_profiler",
"disabled-by-default-v8.cpu_profiler.hires",
"v8",
"disabled-by-default-v8.runtime_stats",
"blink",
"blink.user_timing",
"latencyInfo",
"renderer.scheduler",
"sequence_manager",
"toplevel",
];
pub struct TracingState {
pub active: bool,
pub events: Vec<Value>,
pub events_dropped: bool,
}
impl TracingState {
pub fn new() -> Self {
Self {
active: false,
events: Vec::new(),
events_dropped: false,
}
}
}
pub async fn trace_start(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
) -> Result<Value, String> {
if tracing_state.active {
return Err("Tracing already active".to_string());
}
client
.send_command(
"Tracing.start",
Some(json!({
"traceConfig": {
"recordMode": "recordContinuously",
},
"transferMode": "ReturnAsStream",
})),
Some(session_id),
)
.await?;
tracing_state.active = true;
tracing_state.events.clear();
tracing_state.events_dropped = false;
Ok(json!({ "started": true }))
}
pub async fn trace_stop(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
path: Option<&str>,
) -> Result<Value, String> {
if !tracing_state.active {
return Err("No tracing in progress".to_string());
}
// Subscribe to events before stopping
let mut rx = client.subscribe();
client
.send_command_no_params("Tracing.end", Some(session_id))
.await?;
// Collect trace data with timeout
let mut trace_events: Vec<Value> = Vec::new();
let mut stream_handle: Option<String> = None;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
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) {
continue;
}
match event.method.as_str() {
"Tracing.dataCollected" => {
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
trace_events.extend(arr.iter().cloned());
}
}
"Tracing.tracingComplete" => {
stream_handle = event
.params
.get("stream")
.and_then(|v| v.as_str())
.map(String::from);
break;
}
_ => {}
}
}
Ok(Err(_)) => break,
Err(_) => {
return Err("Tracing stop timed out after 30s".to_string());
}
}
}
// If ReturnAsStream mode was used, read trace data from the IO stream
if let Some(handle) = stream_handle {
if trace_events.is_empty() {
let stream_data = read_io_stream(client, session_id, &handle).await?;
if let Ok(parsed) = serde_json::from_str::<Value>(&stream_data) {
if let Some(events) = parsed.get("traceEvents").and_then(|v| v.as_array()) {
trace_events.extend(events.iter().cloned());
}
} else {
// Try parsing as newline-delimited JSON
for line in stream_data.lines() {
if let Ok(val) = serde_json::from_str::<Value>(line) {
if let Some(events) = val.get("traceEvents").and_then(|v| v.as_array()) {
trace_events.extend(events.iter().cloned());
} else {
trace_events.push(val);
}
}
}
}
}
// Close the IO stream
let _ = client
.send_command(
"IO.close",
Some(json!({ "handle": handle })),
Some(session_id),
)
.await;
}
tracing_state.active = false;
let save_path = match path {
Some(p) => p.to_string(),
None => {
let dir = get_traces_dir();
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
dir.join(format!("trace-{}.json", timestamp))
.to_string_lossy()
.to_string()
}
};
let trace_json = json!({ "traceEvents": trace_events });
let json_str = serde_json::to_string(&trace_json)
.map_err(|e| format!("Failed to serialize trace: {}", e))?;
std::fs::write(&save_path, json_str)
.map_err(|e| format!("Failed to write trace to {}: {}", save_path, e))?;
Ok(json!({ "path": save_path, "eventCount": trace_events.len() }))
}
pub async fn profiler_start(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
categories: Option<Vec<String>>,
) -> Result<Value, String> {
if tracing_state.active {
return Err("Profiling/tracing already active".to_string());
}
let cats: Vec<String> = categories.unwrap_or_else(|| {
DEFAULT_PROFILER_CATEGORIES
.iter()
.map(|s| s.to_string())
.collect()
});
client
.send_command(
"Tracing.start",
Some(json!({
"traceConfig": {
"includedCategories": cats,
"enableSampling": true,
},
"transferMode": "ReportEvents",
})),
Some(session_id),
)
.await?;
tracing_state.active = true;
tracing_state.events.clear();
tracing_state.events_dropped = false;
Ok(json!({ "started": true }))
}
pub async fn profiler_stop(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
path: Option<&str>,
) -> Result<Value, String> {
if !tracing_state.active {
return Err("No profiling in progress".to_string());
}
let mut rx = client.subscribe();
client
.send_command_no_params("Tracing.end", Some(session_id))
.await?;
let mut events: Vec<Value> = Vec::new();
let mut dropped = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
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) {
continue;
}
match event.method.as_str() {
"Tracing.dataCollected" => {
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
if events.len() + arr.len() > MAX_PROFILE_EVENTS {
dropped = true;
} else {
events.extend(arr.iter().cloned());
}
}
}
"Tracing.tracingComplete" => {
break;
}
_ => {}
}
}
Ok(Err(_)) => break,
Err(_) => {
return Err("Profiler stop timed out after 30s".to_string());
}
}
}
tracing_state.active = false;
let save_path = match path {
Some(p) => p.to_string(),
None => {
let dir = get_profiles_dir();
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
dir.join(format!("profile-{}.json", timestamp))
.to_string_lossy()
.to_string()
}
};
let clock_domain = get_clock_domain();
let mut profile = json!({ "traceEvents": events });
if let Some(cd) = clock_domain {
profile
.as_object_mut()
.unwrap()
.insert("metadata".to_string(), json!({ "clock-domain": cd }));
}
let json_str = serde_json::to_string(&profile)
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
std::fs::write(&save_path, json_str)
.map_err(|e| format!("Failed to write profile to {}: {}", save_path, e))?;
let event_count = events.len();
let mut result = json!({ "path": save_path, "eventCount": event_count });
if dropped {
result.as_object_mut().unwrap().insert(
"warning".to_string(),
Value::String(format!(
"Events exceeded {} limit; some dropped",
MAX_PROFILE_EVENTS
)),
);
}
Ok(result)
}
/// Read all data from a CDP IO stream handle.
async fn read_io_stream(
client: &CdpClient,
session_id: &str,
handle: &str,
) -> Result<String, String> {
let mut data = String::new();
loop {
let result = client
.send_command(
"IO.read",
Some(json!({
"handle": handle,
"size": 1024 * 1024,
})),
Some(session_id),
)
.await?;
if let Some(chunk) = result.get("data").and_then(|v| v.as_str()) {
data.push_str(chunk);
}
let eof = result.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
if eof {
break;
}
}
Ok(data)
}
fn get_clock_domain() -> Option<&'static str> {
if cfg!(target_os = "linux") {
Some("LINUX_CLOCK_MONOTONIC")
} else if cfg!(target_os = "macos") {
Some("MAC_MACH_ABSOLUTE_TIME")
} else {
None
}
}
fn get_traces_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("traces")
} else {
std::env::temp_dir().join("agent-browser").join("traces")
}
}
fn get_profiles_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("profiles")
} else {
std::env::temp_dir().join("agent-browser").join("profiles")
}
}
+240
View File
@@ -0,0 +1,240 @@
use serde_json::{json, Value};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use super::client::WebDriverClient;
const APPIUM_DEFAULT_PORT: u16 = 4723;
const APPIUM_STARTUP_TIMEOUT_SECS: u64 = 30;
pub struct AppiumManager {
pub client: WebDriverClient,
appium_process: Option<Child>,
pub device_udid: Option<String>,
}
impl AppiumManager {
pub async fn connect_or_launch(device_udid: Option<&str>) -> Result<Self, String> {
let port = APPIUM_DEFAULT_PORT;
let client = WebDriverClient::new(port);
// Check if Appium is already running
if is_appium_running(port).await {
return Ok(Self {
client,
appium_process: None,
device_udid: device_udid.map(String::from),
});
}
// Try to launch Appium
let appium_process = launch_appium(port)?;
// Wait for Appium to be ready
wait_for_appium(port, APPIUM_STARTUP_TIMEOUT_SECS).await?;
Ok(Self {
client,
appium_process: Some(appium_process),
device_udid: device_udid.map(String::from),
})
}
pub fn build_ios_capabilities(
device_udid: Option<&str>,
device_name: Option<&str>,
platform_version: Option<&str>,
) -> Value {
let mut caps = json!({
"platformName": "iOS",
"appium:automationName": "XCUITest",
"browserName": "Safari",
"appium:noReset": true,
});
if let Some(name) = device_name {
caps["appium:deviceName"] = json!(name);
} else {
caps["appium:deviceName"] = json!("iPhone");
}
if let Some(ver) = platform_version {
caps["appium:platformVersion"] = json!(ver);
}
if let Some(udid) = device_udid {
caps["appium:udid"] = json!(udid);
}
caps
}
pub async fn create_ios_session(
&mut self,
device_name: Option<&str>,
platform_version: Option<&str>,
) -> Result<Value, String> {
let caps = Self::build_ios_capabilities(
self.device_udid.as_deref(),
device_name,
platform_version,
);
self.client.create_session(caps).await
}
pub async fn tap(&self, x: f64, y: f64) -> Result<(), String> {
let sid = self
.client
.session_id_pub()
.ok_or("No active session")?
.to_string();
let actions = json!({
"actions": [{
"type": "pointer",
"id": "finger1",
"parameters": { "pointerType": "touch" },
"actions": [
{ "type": "pointerMove", "duration": 0, "x": x as i64, "y": y as i64 },
{ "type": "pointerDown", "button": 0 },
{ "type": "pause", "duration": 100 },
{ "type": "pointerUp", "button": 0 },
]
}]
});
self.client.execute_actions(&sid, &actions).await
}
pub async fn swipe(
&self,
start_x: f64,
start_y: f64,
end_x: f64,
end_y: f64,
duration_ms: u64,
) -> Result<(), String> {
let sid = self
.client
.session_id_pub()
.ok_or("No active session")?
.to_string();
let actions = json!({
"actions": [{
"type": "pointer",
"id": "finger1",
"parameters": { "pointerType": "touch" },
"actions": [
{ "type": "pointerMove", "duration": 0, "x": start_x as i64, "y": start_y as i64 },
{ "type": "pointerDown", "button": 0 },
{ "type": "pointerMove", "duration": duration_ms, "x": end_x as i64, "y": end_y as i64 },
{ "type": "pointerUp", "button": 0 },
]
}]
});
self.client.execute_actions(&sid, &actions).await
}
pub async fn close(&mut self) -> Result<(), String> {
let _ = self.client.delete_session().await;
if let Some(ref mut child) = self.appium_process {
let _ = child.kill();
let _ = child.wait();
}
Ok(())
}
}
impl Drop for AppiumManager {
fn drop(&mut self) {
if let Some(ref mut child) = self.appium_process {
let _ = child.kill();
let _ = child.wait();
}
}
}
async fn is_appium_running(port: u16) -> bool {
let addr = format!("127.0.0.1:{}", port);
tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(&addr),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false)
}
fn launch_appium(port: u16) -> Result<Child, String> {
// Try npx appium first, then direct appium
let result = Command::new("npx")
.args(["appium", "--relaxed-security", "--port", &port.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match result {
Ok(child) => Ok(child),
Err(_) => Command::new("appium")
.args(["--relaxed-security", "--port", &port.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"Failed to launch Appium. Install it with: npm install -g appium. Error: {}",
e
)
}),
}
}
async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
if tokio::time::Instant::now() > deadline {
return Err("Timeout waiting for Appium to start".to_string());
}
if is_appium_running(port).await {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_appium_constants() {
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
}
#[test]
fn test_ios_capabilities_use_vendor_prefix() {
let caps = AppiumManager::build_ios_capabilities(
Some("TEST-UDID-123"),
Some("iPhone 16 Pro"),
Some("18.5"),
);
// W3C standard capabilities must NOT have vendor prefix
assert!(caps.get("platformName").is_some());
assert!(caps.get("browserName").is_some());
// Non-standard capabilities MUST have appium: vendor prefix
assert!(caps.get("appium:automationName").is_some());
assert!(caps.get("appium:noReset").is_some());
assert!(caps.get("appium:deviceName").is_some());
assert!(caps.get("appium:platformVersion").is_some());
assert!(caps.get("appium:udid").is_some());
// Must NOT have unprefixed non-standard capabilities
assert!(caps.get("automationName").is_none());
assert!(caps.get("noReset").is_none());
assert!(caps.get("deviceName").is_none());
assert!(caps.get("udid").is_none());
}
}
+142
View File
@@ -0,0 +1,142 @@
use async_trait::async_trait;
use serde_json::Value;
/// Abstract backend for browser automation. CDP (Chromium) and WebDriver
/// (Safari/iOS) share this interface so actions.rs can remain backend-agnostic
/// in the future.
#[async_trait]
pub trait BrowserBackend: Send + Sync {
async fn navigate(&self, url: &str) -> Result<(), String>;
async fn get_url(&self) -> Result<String, String>;
async fn get_title(&self) -> Result<String, String>;
async fn get_content(&self) -> Result<String, String>;
async fn evaluate(&self, script: &str) -> Result<Value, String>;
async fn screenshot(&self) -> Result<String, String>;
async fn click(&self, selector: &str) -> Result<(), String>;
async fn fill(&self, selector: &str, value: &str) -> Result<(), String>;
async fn close(&mut self) -> Result<(), String>;
async fn back(&self) -> Result<(), String>;
async fn forward(&self) -> Result<(), String>;
async fn reload(&self) -> Result<(), String>;
async fn get_cookies(&self) -> Result<Value, String>;
fn backend_type(&self) -> &str;
fn supports(&self, feature: &str) -> bool {
match feature {
"navigate" | "evaluate" | "screenshot" | "click" | "fill" => true,
"screencast" | "tracing" | "network_intercept" | "cdp" => self.backend_type() == "cdp",
_ => false,
}
}
fn unsupported_error(&self, action: &str) -> String {
format!(
"Action '{}' is not supported on the {} backend",
action,
self.backend_type()
)
}
}
/// WebDriver implementation of BrowserBackend
pub struct WebDriverBackend {
client: super::client::WebDriverClient,
}
impl WebDriverBackend {
pub fn new(client: super::client::WebDriverClient) -> Self {
Self { client }
}
}
#[async_trait]
impl BrowserBackend for WebDriverBackend {
async fn navigate(&self, url: &str) -> Result<(), String> {
self.client.navigate(url).await
}
async fn get_url(&self) -> Result<String, String> {
self.client.get_url().await
}
async fn get_title(&self) -> Result<String, String> {
self.client.get_title().await
}
async fn get_content(&self) -> Result<String, String> {
self.client.get_page_source().await
}
async fn evaluate(&self, script: &str) -> Result<Value, String> {
self.client.execute_script(script, vec![]).await
}
async fn screenshot(&self) -> Result<String, String> {
self.client.screenshot().await
}
async fn click(&self, selector: &str) -> Result<(), String> {
let element_id = self.client.find_element("css selector", selector).await?;
self.client.click_element(&element_id).await
}
async fn fill(&self, selector: &str, value: &str) -> Result<(), String> {
let element_id = self.client.find_element("css selector", selector).await?;
self.client.clear_element(&element_id).await?;
self.client.send_keys(&element_id, value).await
}
async fn close(&mut self) -> Result<(), String> {
self.client.delete_session().await
}
async fn back(&self) -> Result<(), String> {
self.client.back().await
}
async fn forward(&self) -> Result<(), String> {
self.client.forward().await
}
async fn reload(&self) -> Result<(), String> {
self.client.refresh().await
}
async fn get_cookies(&self) -> Result<Value, String> {
self.client.get_cookies().await
}
fn backend_type(&self) -> &str {
"webdriver"
}
}
/// CDP-backed backend constants for unsupported actions on WebDriver
pub const WEBDRIVER_UNSUPPORTED_ACTIONS: &[&str] = &[
"screencast_start",
"screencast_stop",
"trace_start",
"trace_stop",
"profiler_start",
"profiler_stop",
"route",
"unroute",
"expose",
"addscript",
"addinitscript",
"network",
"har_start",
"har_stop",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unsupported_actions() {
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"screencast_start"));
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"trace_start"));
assert!(!WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"navigate"));
}
}
+318
View File
@@ -0,0 +1,318 @@
use serde_json::{json, Value};
use std::time::Duration;
pub struct WebDriverClient {
base_url: String,
session_id: Option<String>,
}
impl WebDriverClient {
pub fn new(port: u16) -> Self {
Self {
base_url: format!("http://127.0.0.1:{}", port),
session_id: None,
}
}
pub async fn create_session(&mut self, capabilities: Value) -> Result<Value, String> {
let body = json!({
"capabilities": {
"alwaysMatch": capabilities,
}
});
let response = self.post("/session", &body).await?;
let session_id = response
.get("value")
.and_then(|v| v.get("sessionId"))
.and_then(|v| v.as_str())
.ok_or("No sessionId in response")?
.to_string();
self.session_id = Some(session_id);
Ok(response)
}
pub async fn delete_session(&mut self) -> Result<(), String> {
if let Some(ref sid) = self.session_id.clone() {
let _ = self.delete(&format!("/session/{}", sid)).await;
self.session_id = None;
}
Ok(())
}
pub async fn navigate(&self, url: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/url", sid), &json!({ "url": url }))
.await?;
Ok(())
}
pub async fn get_url(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/url", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn get_title(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/title", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn find_element(&self, using: &str, value: &str) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self
.post(
&format!("/session/{}/element", sid),
&json!({ "using": using, "value": value }),
)
.await?;
let element_value = response.get("value").ok_or("No element in response")?;
element_value
.get("element-6066-11e4-a52e-4f735466cecf")
.or_else(|| element_value.get("ELEMENT"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or("No element ID in response".to_string())
}
pub async fn click_element(&self, element_id: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(
&format!("/session/{}/element/{}/click", sid, element_id),
&json!({}),
)
.await?;
Ok(())
}
pub async fn send_keys(&self, element_id: &str, text: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(
&format!("/session/{}/element/{}/value", sid, element_id),
&json!({ "text": text }),
)
.await?;
Ok(())
}
pub async fn clear_element(&self, element_id: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(
&format!("/session/{}/element/{}/clear", sid, element_id),
&json!({}),
)
.await?;
Ok(())
}
pub async fn execute_script(&self, script: &str, args: Vec<Value>) -> Result<Value, String> {
let sid = self.session_id()?.to_string();
let response = self
.post(
&format!("/session/{}/execute/sync", sid),
&json!({ "script": script, "args": args }),
)
.await?;
Ok(response.get("value").cloned().unwrap_or(Value::Null))
}
pub async fn screenshot(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/screenshot", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn get_cookies(&self) -> Result<Value, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/cookie", sid)).await?;
Ok(response.get("value").cloned().unwrap_or(Value::Null))
}
pub async fn get_page_source(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/source", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn back(&self) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/back", sid), &json!({}))
.await?;
Ok(())
}
pub async fn forward(&self) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/forward", sid), &json!({}))
.await?;
Ok(())
}
pub async fn refresh(&self) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/refresh", sid), &json!({}))
.await?;
Ok(())
}
pub fn session_id_pub(&self) -> Option<&str> {
self.session_id.as_deref()
}
pub fn new_with_session(port: u16, session_id: String) -> Self {
Self {
base_url: format!("http://127.0.0.1:{}", port),
session_id: Some(session_id),
}
}
pub async fn execute_actions(&self, session_id: &str, actions: &Value) -> Result<(), String> {
self.post(&format!("/session/{}/actions", session_id), actions)
.await?;
Ok(())
}
fn session_id(&self) -> Result<&str, String> {
self.session_id
.as_deref()
.ok_or("No active WebDriver session".to_string())
}
async fn get(&self, path: &str) -> Result<Value, String> {
http_request("GET", &format!("{}{}", self.base_url, path), None).await
}
async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
http_request("POST", &format!("{}{}", self.base_url, path), Some(body)).await
}
async fn delete(&self, path: &str) -> Result<Value, String> {
http_request("DELETE", &format!("{}{}", self.base_url, path), None).await
}
}
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(80);
let path = parsed.path();
let addr = format!("{}:{}", host, port);
let stream = tokio::time::timeout(
Duration::from_secs(10),
tokio::net::TcpStream::connect(&addr),
)
.await
.map_err(|_| format!("Connection timeout: {}", addr))?
.map_err(|e| format!("Connection failed: {}", e))?;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let body_str = body
.map(|b| serde_json::to_string(b).unwrap_or_default())
.unwrap_or_default();
let request = if body.is_some() {
format!(
"{} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
method, path, addr, body_str.len(), body_str
)
} else {
format!(
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
method, path, addr
)
};
let mut stream = stream;
stream
.write_all(request.as_bytes())
.await
.map_err(|e| format!("Write failed: {}", e))?;
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.await
.map_err(|e| format!("Read failed: {}", e))?;
let response_str = String::from_utf8_lossy(&response);
let body_part = response_str.split("\r\n\r\n").nth(1).unwrap_or("").trim();
// Handle chunked encoding
let json_body = if body_part.contains('\n')
&& body_part
.chars()
.next()
.map(|c| c.is_ascii_hexdigit())
.unwrap_or(false)
{
// Chunked: skip chunk size lines
body_part
.lines()
.filter(|l| !l.chars().all(|c| c.is_ascii_hexdigit() || c == '\r'))
.collect::<Vec<&str>>()
.join("")
} else {
body_part.to_string()
};
if json_body.is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&json_body).map_err(|e| {
format!(
"Invalid JSON response: {} (body: {})",
e,
json_body.chars().take(100).collect::<String>()
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_new() {
let client = WebDriverClient::new(4444);
assert_eq!(client.base_url, "http://127.0.0.1:4444");
assert!(client.session_id.is_none());
}
#[test]
fn test_session_id_none() {
let client = WebDriverClient::new(4444);
let result = client.session_id();
assert!(result.is_err());
assert!(result.unwrap_err().contains("No active WebDriver session"));
}
#[test]
fn test_client_custom_port() {
let client = WebDriverClient::new(9515);
assert_eq!(client.base_url, "http://127.0.0.1:9515");
}
}
+235
View File
@@ -0,0 +1,235 @@
use serde_json::{json, Value};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct IosDevice {
pub name: String,
pub udid: String,
pub state: String,
pub runtime: String,
pub is_real: bool,
}
pub fn list_simulators() -> Result<Vec<IosDevice>, String> {
let output = Command::new("xcrun")
.args(["simctl", "list", "devices", "--json"])
.output()
.map_err(|e| format!("Failed to run xcrun simctl: {}", e))?;
if !output.status.success() {
return Err("xcrun simctl failed. Xcode may not be installed.".to_string());
}
let json_str = String::from_utf8_lossy(&output.stdout);
let parsed: Value =
serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse simctl: {}", e))?;
let mut devices = Vec::new();
if let Some(device_map) = parsed.get("devices").and_then(|v| v.as_object()) {
for (runtime, device_list) in device_map {
if let Some(arr) = device_list.as_array() {
for device in arr {
let name = device
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let udid = device
.get("udid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let state = device
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
devices.push(IosDevice {
name,
udid,
state,
runtime: runtime.clone(),
is_real: false,
});
}
}
}
}
Ok(devices)
}
pub fn list_real_devices() -> Result<Vec<IosDevice>, String> {
let output = Command::new("xcrun")
.args(["xctrace", "list", "devices"])
.output()
.map_err(|e| format!("Failed to run xcrun xctrace: {}", e))?;
if !output.status.success() {
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut devices = Vec::new();
let mut in_devices = false;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("== Devices ==") {
in_devices = true;
continue;
}
if trimmed.starts_with("== Simulators ==") {
break;
}
if !in_devices || trimmed.is_empty() {
continue;
}
// Format: "Device Name (OS Version) (UDID)"
if let Some(udid_start) = trimmed.rfind('(') {
let udid_end = trimmed.len() - 1;
let udid = &trimmed[udid_start + 1..udid_end];
// Validate it looks like a UDID (contains hyphens)
if udid.contains('-') && udid.len() > 20 {
let name_part = trimmed[..udid_start].trim();
let name = if let Some(paren_pos) = name_part.rfind('(') {
name_part[..paren_pos].trim().to_string()
} else {
name_part.to_string()
};
devices.push(IosDevice {
name,
udid: udid.to_string(),
state: "Connected".to_string(),
runtime: String::new(),
is_real: true,
});
}
}
}
Ok(devices)
}
pub fn list_all_devices() -> Result<Vec<IosDevice>, String> {
let mut all = list_simulators().unwrap_or_default();
all.extend(list_real_devices().unwrap_or_default());
Ok(all)
}
pub fn boot_simulator(udid: &str) -> Result<(), String> {
let output = Command::new("xcrun")
.args(["simctl", "boot", udid])
.output()
.map_err(|e| format!("Failed to boot simulator: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("current state: Booted") {
return Ok(());
}
return Err(format!("Failed to boot simulator {}: {}", udid, stderr));
}
Ok(())
}
pub fn shutdown_simulator(udid: &str) -> Result<(), String> {
let output = Command::new("xcrun")
.args(["simctl", "shutdown", udid])
.output()
.map_err(|e| format!("Failed to shutdown simulator: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("current state: Shutdown") {
return Ok(());
}
return Err(format!("Failed to shutdown simulator {}: {}", udid, stderr));
}
Ok(())
}
pub fn select_device(device_name: Option<&str>, udid: Option<&str>) -> Result<IosDevice, String> {
if let Some(u) = udid {
let devices = list_all_devices()?;
return devices
.into_iter()
.find(|d| d.udid == u)
.ok_or_else(|| format!("Device with UDID '{}' not found", u));
}
if let Some(name) = device_name {
let devices = list_all_devices()?;
return devices
.into_iter()
.find(|d| d.name.to_lowercase().contains(&name.to_lowercase()))
.ok_or_else(|| format!("Device '{}' not found", name));
}
// Default: prefer most recent iPhone, prefer Pro
let devices = list_simulators()?;
let iphone_devices: Vec<&IosDevice> = devices
.iter()
.filter(|d| d.name.starts_with("iPhone"))
.collect();
if iphone_devices.is_empty() {
return devices
.into_iter()
.next()
.ok_or("No iOS simulators found".to_string());
}
// Prefer Pro models
if let Some(pro) = iphone_devices.iter().find(|d| d.name.contains("Pro")) {
return Ok((*pro).clone());
}
Ok((*iphone_devices.last().unwrap()).clone())
}
pub fn to_device_json(devices: &[IosDevice]) -> Value {
let list: Vec<Value> = devices
.iter()
.map(|d| {
json!({
"name": d.name,
"udid": d.udid,
"state": d.state,
"runtime": d.runtime,
"isReal": d.is_real,
})
})
.collect();
json!({ "devices": list })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ios_device_struct() {
let device = IosDevice {
name: "iPhone 15 Pro".to_string(),
udid: "ABC-123".to_string(),
state: "Booted".to_string(),
runtime: "iOS-17-0".to_string(),
is_real: false,
};
assert_eq!(device.name, "iPhone 15 Pro");
assert!(!device.is_real);
}
#[test]
fn test_to_device_json() {
let devices = vec![IosDevice {
name: "Test".to_string(),
udid: "123".to_string(),
state: "Shutdown".to_string(),
runtime: "iOS-17".to_string(),
is_real: false,
}];
let json = to_device_json(&devices);
assert!(json.get("devices").unwrap().as_array().unwrap().len() == 1);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod appium;
pub mod backend;
pub mod client;
pub mod ios;
pub mod safari;
pub mod types;
+80
View File
@@ -0,0 +1,80 @@
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
pub struct SafariDriverProcess {
child: Child,
pub port: u16,
}
impl SafariDriverProcess {
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for SafariDriverProcess {
fn drop(&mut self) {
self.kill();
}
}
pub fn find_safaridriver() -> Option<PathBuf> {
let candidates = ["/usr/bin/safaridriver"];
for c in &candidates {
let p = PathBuf::from(c);
if p.exists() {
return Some(p);
}
}
// Try PATH
if let Ok(output) = Command::new("which").arg("safaridriver").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
None
}
pub fn launch_safaridriver(port: u16) -> Result<SafariDriverProcess, String> {
let driver_path = find_safaridriver()
.ok_or("safaridriver not found. Safari WebDriver requires macOS with Safari.")?;
let child = Command::new(&driver_path)
.arg("--port")
.arg(port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to launch safaridriver: {}", e))?;
// Wait for driver to be ready
std::thread::sleep(Duration::from_millis(500));
Ok(SafariDriverProcess { child, port })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_safaridriver() {
// Only check on macOS
if cfg!(target_os = "macos") {
let result = find_safaridriver();
// Don't assert Some since it may not be enabled
if let Some(path) = result {
assert!(path.exists());
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSessionRequest {
pub capabilities: Capabilities,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Capabilities {
pub always_match: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionResponse {
pub value: SessionValue,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionValue {
pub session_id: String,
pub capabilities: Value,
}
#[derive(Debug, Deserialize)]
pub struct WebDriverResponse {
pub value: Value,
}
#[derive(Debug, Deserialize)]
pub struct WebDriverError {
pub error: String,
pub message: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElementResponse {
pub value: ElementValue,
}
#[derive(Debug, Deserialize)]
pub struct ElementValue {
#[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
pub element_id: Option<String>,
#[serde(rename = "ELEMENT")]
pub element_legacy: Option<String>,
}
impl ElementValue {
pub fn id(&self) -> Option<&str> {
self.element_id
.as_deref()
.or(self.element_legacy.as_deref())
}
}
#[derive(Debug, Serialize)]
pub struct FindElementRequest {
pub using: String,
pub value: String,
}
#[derive(Debug, Serialize)]
pub struct ExecuteScriptRequest {
pub script: String,
pub args: Vec<Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieRequest {
pub cookie: CookieData,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieData {
pub name: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
}
+856 -110
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
use std::sync::{Mutex, MutexGuard};
/// Global mutex shared across all test modules to prevent parallel tests from
/// interfering with each other when mutating environment variables.
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
pub struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
impl<'a> EnvGuard<'a> {
pub fn new(var_names: &[&str]) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
let vars = var_names
.iter()
.map(|&name| (name.to_string(), std::env::var(name).ok()))
.collect();
Self { _lock: lock, vars }
}
pub fn set(&self, name: &str, value: &str) {
debug_assert!(
self.vars.iter().any(|(n, _)| n == name),
"EnvGuard::set called with unregistered var: {name}"
);
std::env::set_var(name, value);
}
pub fn remove(&self, name: &str) {
debug_assert!(
self.vars.iter().any(|(n, _)| n == name),
"EnvGuard::remove called with unregistered var: {name}"
);
std::env::remove_var(name);
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
for (name, value) in &self.vars {
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
+284
View File
@@ -0,0 +1,284 @@
use crate::color;
use std::path::Path;
use std::process::{exit, Command, Stdio};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
enum InstallMethod {
Npm,
Pnpm,
Yarn,
Bun,
Homebrew,
Cargo,
Unknown,
}
async fn fetch_latest_version() -> Result<String, String> {
let resp = reqwest::get(NPM_REGISTRY_URL)
.await
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse version info: {}", e))?;
body.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No version field in registry response".to_string())
}
/// Parse the `.install-method` marker written by postinstall.js.
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
match contents.trim() {
"npm" => Some(InstallMethod::Npm),
"pnpm" => Some(InstallMethod::Pnpm),
"yarn" => Some(InstallMethod::Yarn),
"bun" => Some(InstallMethod::Bun),
_ => None,
}
}
fn detect_install_method() -> InstallMethod {
if let Ok(exe) = std::env::current_exe() {
// Resolve symlinks to find the real binary location
let real_path = exe.canonicalize().unwrap_or(exe);
// Preferred: read the marker file written at install time
if let Some(dir) = real_path.parent() {
if let Some(method) = read_install_method_marker(dir) {
return method;
}
}
// Fallback: infer from executable path
let path_str = real_path.to_string_lossy();
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
return InstallMethod::Cargo;
}
if path_str.contains("/Cellar/agent-browser/")
|| path_str.contains("/homebrew/")
|| path_str.contains("/linuxbrew/")
{
return InstallMethod::Homebrew;
}
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
return InstallMethod::Pnpm;
}
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
return InstallMethod::Yarn;
}
if path_str.contains("/.bun/") {
return InstallMethod::Bun;
}
if path_str.contains("node_modules/agent-browser")
|| path_str.contains("node_modules\\agent-browser")
{
return InstallMethod::Npm;
}
}
// Last resort: probe package managers via subprocess
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if command_succeeds("brew", &["list", "agent-browser"]) {
return InstallMethod::Homebrew;
}
}
if command_output_contains(
"pnpm",
&["list", "-g", "agent-browser", "--depth=0"],
"agent-browser",
) {
return InstallMethod::Pnpm;
}
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
return InstallMethod::Yarn;
}
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
return InstallMethod::Bun;
}
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
return InstallMethod::Npm;
}
InstallMethod::Unknown
}
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
Command::new(cmd)
.args(args)
.stderr(Stdio::null())
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
.unwrap_or(false)
}
fn run_upgrade_command(method: &InstallMethod) -> bool {
let (cmd, args, display): (&str, &[&str], &str) = match method {
InstallMethod::Npm => (
"npm",
&["install", "-g", "agent-browser@latest"],
"npm install -g agent-browser@latest",
),
InstallMethod::Pnpm => (
"pnpm",
&["add", "-g", "agent-browser@latest"],
"pnpm add -g agent-browser@latest",
),
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
InstallMethod::Yarn => (
"yarn",
&["global", "add", "agent-browser@latest"],
"yarn global add agent-browser@latest",
),
InstallMethod::Bun => (
"bun",
&["install", "-g", "agent-browser@latest"],
"bun install -g agent-browser@latest",
),
InstallMethod::Homebrew => (
"brew",
&["upgrade", "agent-browser"],
"brew upgrade agent-browser",
),
InstallMethod::Cargo => (
"cargo",
&["install", "agent-browser", "--force"],
"cargo install agent-browser --force",
),
InstallMethod::Unknown => return false,
};
println!("Running: {}", display);
Command::new(cmd)
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn run_upgrade() {
let current = CURRENT_VERSION;
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 latest = match rt.block_on(fetch_latest_version()) {
Ok(v) => v,
Err(e) => {
eprintln!(
"{} Could not check latest version: {}",
color::warning_indicator(),
e
);
String::new()
}
};
if !latest.is_empty() && current == latest.as_str() {
println!(
"{} agent-browser is already at the latest version (v{})",
color::success_indicator(),
current
);
return;
}
let method = detect_install_method();
let method_name = match &method {
InstallMethod::Npm => "npm",
InstallMethod::Pnpm => "pnpm",
InstallMethod::Yarn => "yarn",
InstallMethod::Bun => "bun",
InstallMethod::Homebrew => "Homebrew",
InstallMethod::Cargo => "Cargo",
InstallMethod::Unknown => "",
};
if matches!(method, InstallMethod::Unknown) {
eprintln!(
"{} Could not detect installation method.",
color::error_indicator()
);
eprintln!(" To update manually, run one of:");
eprintln!(" npm install -g agent-browser@latest # npm");
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
eprintln!(" yarn global add agent-browser@latest # yarn");
eprintln!(" bun install -g agent-browser@latest # bun");
eprintln!(" brew upgrade agent-browser # Homebrew");
eprintln!(" cargo install agent-browser --force # Cargo");
exit(1);
}
println!("Detected installation via {}.", method_name);
if !latest.is_empty() {
println!(
"{}",
color::cyan(&format!(
"Upgrading agent-browser... v{} → v{}",
current, latest
))
);
} else {
println!(
"{}",
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
);
}
let success = run_upgrade_command(&method);
if success {
if !latest.is_empty() {
println!(
"{} Done! v{} → v{}",
color::success_indicator(),
current,
latest
);
} else {
println!("{} Done!", color::success_indicator());
}
} else {
eprintln!("{} Upgrade failed.", color::error_indicator());
exit(1);
}
}
+1 -1
View File
@@ -42,7 +42,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}
+12 -8
View File
@@ -102,9 +102,9 @@ importers:
tailwindcss:
specifier: ^4
version: 4.1.18
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.18)
typescript:
specifier: ^5
version: 5.9.3
@@ -3467,6 +3467,11 @@ packages:
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
@@ -3521,9 +3526,6 @@ packages:
turndown@7.2.2:
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -7827,6 +7829,10 @@ snapshots:
tailwind-merge@3.4.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.18):
dependencies:
tailwindcss: 4.1.18
tailwindcss@4.1.18: {}
tapable@2.3.0: {}
@@ -7891,8 +7897,6 @@ snapshots:
dependencies:
'@mixmark-io/domino': 2.2.0
tw-animate-css@1.4.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
+3 -3
View File
@@ -12,11 +12,11 @@ export const maxDuration = 60;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a browser automation CLI designed for AI agents.
GitHub repository: https://github.com/leeguooooo/agent-browser
GitHub repository: https://github.com/vercel-labs/agent-browser
Documentation: https://agent-browser.dev
npm package: agent-browser-stealth
npm package: agent-browser
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
+40
View File
@@ -0,0 +1,40 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { NextRequest, NextResponse } from "next/server";
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const docPath = searchParams.get("path");
if (!docPath) {
return NextResponse.json(
{ error: "Missing ?path= parameter" },
{ status: 400 },
);
}
const normalized = docPath
.replace(/^\//, "")
.replace(/\.\./g, "")
.replace(/[^a-zA-Z0-9/_-]/g, "");
const slug = normalized;
const filePath = slug
? join(process.cwd(), "src", "app", ...slug.split("/"), "page.mdx")
: join(process.cwd(), "src", "app", "page.mdx");
try {
const raw = await readFile(filePath, "utf-8");
const markdown = mdxToCleanMarkdown(raw);
return new NextResponse(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
} catch {
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { getSearchIndex } from "@/lib/search-index";
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
if (!q) {
return NextResponse.json({ results: [] });
}
const index = await getSearchIndex();
const terms = q.split(/\s+/).filter(Boolean);
const results = index
.map((entry) => {
const titleLower = entry.title.toLowerCase();
const contentLower = entry.content.toLowerCase();
const titleMatch = terms.every((t) => titleLower.includes(t));
const contentMatch = terms.every((t) => contentLower.includes(t));
if (!titleMatch && !contentMatch) return null;
let snippet = "";
if (contentMatch) {
const firstTermIdx = Math.min(
...terms.map((t) => {
const idx = contentLower.indexOf(t);
return idx === -1 ? Infinity : idx;
}),
);
if (firstTermIdx !== Infinity) {
const start = Math.max(0, firstTermIdx - 40);
const end = Math.min(entry.content.length, firstTermIdx + 120);
snippet =
(start > 0 ? "..." : "") +
entry.content.slice(start, end).replace(/\n/g, " ") +
(end < entry.content.length ? "..." : "");
}
}
return {
title: entry.title,
href: entry.href,
section: entry.section,
snippet,
score: titleMatch ? 2 : 1,
};
})
.filter(
(
r,
): r is {
title: string;
href: string;
section: string;
snippet: string;
score: number;
} => r !== null,
)
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map(({ score: _, ...rest }) => rest);
return NextResponse.json(
{ results },
{ headers: { "Cache-Control": "public, max-age=60" } },
);
}
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("cdp-mode");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+7 -49
View File
@@ -1,18 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("cdp-mode")
# CDP Mode
Connect to an existing browser via Chrome DevTools Protocol:
Default behavior in this fork: when `--cdp` is omitted, agent-browser requires an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no local-launch fallback).
Project policy:
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222
@@ -59,7 +48,8 @@ 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, 9333)
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:
@@ -69,7 +59,7 @@ This is useful when:
## Color scheme
Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference:
Use `--color-scheme` to set a persistent preference when connecting via CDP:
```bash
agent-browser --cdp 9222 --color-scheme dark open https://example.com
@@ -82,23 +72,6 @@ Or set it globally via config or environment variable:
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com
```
## Stealth behavior
`--stealth` is enabled by default across connection modes, but capabilities depend on how you connect:
<table>
<thead>
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
</thead>
<tbody>
<tr><td>Local launch</td><td>Chromium launch args + context init scripts</td></tr>
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
</tbody>
</table>
Use `--debug` to print the active connection type and applied stealth capabilities.
## Use cases
This enables control of:
@@ -117,7 +90,8 @@ This enables control of:
</thead>
<tbody>
<tr><td><code>--session &lt;name&gt;</code></td><td>Use isolated session</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
<tr><td><code>--profile &lt;path&gt;</code></td><td>Persistent browser profile directory</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>, <code>browserless</code>)</td></tr>
<tr><td><code>--headers &lt;json&gt;</code></td><td>HTTP headers scoped to origin</td></tr>
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
<tr><td><code>--args &lt;args&gt;</code></td><td>Browser launch args (comma-separated)</td></tr>
@@ -125,7 +99,6 @@ This enables control of:
<tr><td><code>--proxy &lt;url&gt;</code></td><td>Proxy server URL</td></tr>
<tr><td><code>--proxy-bypass &lt;hosts&gt;</code></td><td>Hosts to bypass proxy</td></tr>
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
<tr><td><code>--headed</code></td><td>Show browser window</td></tr>
@@ -138,25 +111,10 @@ This enables control of:
## Cloud providers
Use cloud browser infrastructure when local browsers aren't available:
Use the `-p` flag to connect to a cloud browser provider instead of launching a local browser:
```bash
# Browserbase
export BROWSERBASE_API_KEY="your-api-key"
export BROWSERBASE_PROJECT_ID="your-project-id"
agent-browser -p browserbase open https://example.com
# Browser Use
export BROWSER_USE_API_KEY="your-api-key"
agent-browser -p browseruse open https://example.com
# Kernel
export KERNEL_API_KEY="your-api-key"
agent-browser -p kernel open https://example.com
# Or via environment variable
export AGENT_BROWSER_PROVIDER=browserbase
agent-browser open https://example.com
```
The `-p` flag takes precedence over `AGENT_BROWSER_PROVIDER`.
See the [Providers](/providers/browser-use) section for setup and configuration of each supported provider: [Browser Use](/providers/browser-use), [Browserbase](/providers/browserbase), [Browserless](/providers/browserless), and [Kernel](/providers/kernel).
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("changelog");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+662 -24
View File
@@ -1,12 +1,650 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("changelog")
# Changelog
## v0.24.0
<p className="text-[#888] text-sm">April 2, 2026</p>
### New Features
- **AWS Bedrock AgentCore provider** - Added AWS Bedrock AgentCore as a cloud browser provider. Connect with `--provider agentcore` or `AGENT_BROWSER_PROVIDER=agentcore`. Uses lightweight manual SigV4 signing for authentication with support for the full AWS credential provider chain (environment variables, AWS CLI, SSO, IAM roles). Configure with `AGENTCORE_REGION`, `AGENTCORE_PROFILE_ID`, and `AGENTCORE_BROWSER_ID` environment variables. Returns session ID and Live View URL in the launch response (#397)
### Documentation
- Added AgentCore provider page to docs site, README options table, SKILL.md, and dashboard provider icons (#1120)
---
## v0.23.4
<p className="text-[#888] text-sm">March 31, 2026</p>
### Bug Fixes
- Fixed **daemon hang on Linux** caused by a `waitpid(-1)` race condition in the SIGCHLD handler that stole exit statuses from Rust's `Child` handles, leaving the daemon in a broken state. Replaced the global signal handler with targeted crash detection via the existing drain interval (#1098)
---
## v0.23.3
<p className="text-[#888] text-sm">March 30, 2026</p>
### Bug Fixes
- Fixed **drag and drop** not working because `mouseMoved` events during the drag omitted the `buttons` bitmask, causing the browser to see `event.buttons === 0` and never fire `dragstart`/`dragover`/`drop` (#1087)
---
## v0.23.2
<p className="text-[#888] text-sm">March 30, 2026</p>
### New Features
- **Dashboard session creation** - Sessions can now be created directly from the dashboard UI. A new session dialog provides a unified selector grid for local engines (Chrome, Lightpanda) and cloud providers (Browserbase, Browserless, Browser Use, Kernel) with async creation, loading state, and error display (#1092)
- **Dashboard provider icons** - The session sidebar now shows the provider or engine icon for each session, making it easy to identify which backend a session is using (#1092)
### Bug Fixes
- Fixed **Browser Use** provider using an intermediate API call instead of connecting directly via WSS, which caused connection failures (#1092)
- Fixed **Browserbase** provider not sending an explicit JSON body and `Content-Type` header, causing session creation to fail (#1092)
- Fixed **provider navigation** hanging because `wait_for_lifecycle` waited for page load events that remote providers may not emit. Navigation with `--provider` now automatically sets `waitUntil=none` (#1092)
- Fixed **remote CDP connections** timing out by increasing the CDP connect timeout from 10s to 25s for cloud providers (#1092)
- Fixed **zombie daemon processes** not being cleaned up when a provider connection fails during session creation from the dashboard (#1092)
---
## v0.23.1
<p className="text-[#888] text-sm">March 30, 2026</p>
### New Features
- **Auto-dismissal for alert and beforeunload dialogs** - JavaScript `alert()` and `beforeunload` dialogs are now automatically accepted to prevent the agent from blocking indefinitely. `confirm` and `prompt` dialogs still require explicit `dialog accept/dismiss` commands. Disable with `--no-auto-dialog` flag or `AGENT_BROWSER_NO_AUTO_DIALOG` environment variable (#1075)
- **Puppeteer browser cache fallback** - Chrome discovery now searches `~/.cache/puppeteer/chrome/` (or `PUPPETEER_CACHE_DIR`) for Chrome binaries, so users with an existing Puppeteer installation can use agent-browser without a separate install step (#1088)
- **Console output improvements** - `console.log` of objects now shows the actual object preview (e.g. `{userId: "abc", count: 42}`) instead of `"Object"`. JSON output includes a raw `args` array for programmatic access (#1040)
### Bug Fixes
- Fixed **same-document navigation** (e.g. SPA hash routing) hanging forever because `wait_for_lifecycle` waited for a `Page.loadEventFired` that never fires on same-document navigations (#1059)
- Fixed **save_state** only capturing cookies and localStorage for the current origin, silently dropping cross-domain data (e.g. SSO/CAS auth cookies). Now uses `Network.getAllCookies` and collects localStorage from all visited origins (#1064)
- Fixed **externally opened tabs** not appearing in `tab list` when using `--cdp` mode. Tabs opened by the user or another CDP client are now detected and tracked (#1042)
- Fixed **dashboard server** not picking up installed files without a restart. `dashboard install` now takes effect immediately on a running server (#1066)
- Fixed **Windows Chrome extraction** failing because zip path normalization used forward slashes while the extraction code expected backslashes (#1088)
---
## v0.23.0
<p className="text-[#888] text-sm">March 27, 2026</p>
### New Features
- **Observability dashboard** - Added a local web UI (`dashboard`) that shows live browser viewports, command activity feeds, console output, network requests, storage, and extensions for all sessions. Manage it with `dashboard start`, `dashboard stop`, and `dashboard install`. The dashboard runs as a standalone background process and all sessions stream to it automatically (#1034)
```bash
agent-browser dashboard install
agent-browser dashboard start
agent-browser open example.com # session appears in dashboard
agent-browser dashboard stop
```
- **Runtime stream management** - Added `stream enable`, `stream disable`, and `stream status` commands to control WebSocket streaming at runtime. Streaming is now always enabled by default; `AGENT_BROWSER_STREAM_PORT` overrides the port instead of toggling the feature (#951)
- **Close all sessions** - Added `close --all` flag to close every active browser session at once
### Bug Fixes
- Fixed **Lightpanda engine** compatibility (#1050)
- Fixed **Windows daemon TCP bind** failing when Hyper-V reserves the port by falling back to an OS-assigned port and writing it to a `.port` file (#1041)
- Fixed **Windows dashboard relay** using Unix socket instead of TCP (#1038)
- Fixed **radio/checkbox elements** being dropped from compact snapshot tree because the `ref=` check required a leading `[` that those elements lack (#1008)
---
## v0.22.3
<p className="text-[#888] text-sm">March 25, 2026</p>
### Bug Fixes
- **Re-apply download behavior on recording context** - Fixed an issue where downloads were silently dropped in recording contexts because `Browser.setDownloadBehavior` set at launch only applied to the default context. The download behavior is now re-applied when a new recording context is created (#1019)
- **Reap zombie Chrome process and fast-detect crash for auto-restart** - Added a non-blocking process-exit check before attempting CDP connection checks. This prevents a 3-second CDP timeout when Chrome has already crashed or exited, enabling faster detection and auto-restart of the browser (#1023)
- **Route keyboard `type` through text input** - Fixed keyboard `type` subaction to correctly route through the text input handler, and added support for an `insertText` subaction using `Input.insertText` (#1014)
- **Handle `--clear` flag in `console` command** - Fixed the `console` command to accept and process a `clear` parameter, allowing console event history to be cleared (#1015)
---
## v0.22.2
<p className="text-[#888] text-sm">March 24, 2026</p>
### New Features
- **Dialog status command** - Added `dialog status` command to check whether a JavaScript dialog is currently open (#999)
- **Dialog warning field** - Command responses now include a `warning` field when a JavaScript dialog is pending, indicating the dialog type and message (#999)
### Improvements
- **Standard proxy environment variables** - The proxy setting now automatically falls back to standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and their lowercase variants), with `NO_PROXY`/`no_proxy` respected for bypass rules (#1000)
- **Font packages for `--with-deps`** - Installing with `--with-deps` now includes CJK and emoji font packages on Linux (Debian, RPM, and yum-based distros) to prevent missing glyphs when rendering international content (#1002)
### Bug Fixes
- Fixed `state show` always failing with "Missing 'path' parameter" due to a mismatched JSON field name (#994)
- Fixed `console` command returning only `Done` due to a JSON field name mismatch in the response (#986)
- Fixed browser-domain CDP events being dropped during downloads due to a `sessionId` mismatch (#998)
- Fixed proxy authentication by handling credentials via the CDP `Fetch.authRequired` event rather than passing them inline (#1000)
---
## v0.22.1
<p className="text-[#888] text-sm">March 24, 2026</p>
### Bug Fixes
- Fixed **modifier key chords** (e.g. `Control+a`, `Shift+Enter`, `Control+Shift+a`) not being handled correctly when using `press`. Modifier keys are now parsed and forwarded as CDP modifier bitmasks rather than treated as part of the key name (#980)
- Fixed **query parameters being dropped** from `--cdp` HTTP URLs (e.g. `http://host:9222?mode=Hello`). Query strings are now preserved and forwarded to the remote CDP endpoint (#982)
---
## v0.22.0
<p className="text-[#888] text-sm">March 23, 2026</p>
### New Features
- **Cross-origin iframe support** - Added support for snapshots and interactions within cross-origin iframes via `Target.setAutoAttach` (#949)
- **Network request detail and filtering** - Added `network request <requestId>` command to view full request/response detail, and new filtering options for `network requests` including `--type` (e.g. `xhr,fetch`), `--method` (e.g. `POST`), and `--status` (e.g. `2xx`, `400-499`) (#935)
```bash
agent-browser network requests --type xhr,fetch --method POST
agent-browser network request <requestId>
```
### Improvements
- **Snapshot usability** - Reduced AI cognitive load by filtering semantic noise from snapshot output; cursor-interactive elements are now included by default, making the `-C` flag unnecessary (#968)
- **Upgrade command** - Improved robustness of installation method detection in the upgrade command (#960)
- **Target tracking** - Enhanced target tracking and page information handling for more reliable browser session management (#969)
### Bug Fixes
- Fixed **viewport dimensions** being reported incorrectly in streaming status messages and screencast (#952)
- Fixed **`find` command** flags such as `--exact` and `--name` leaking into fill values when used with fill actions (#955)
- Fixed **state commands** incorrectly starting the daemon when no `session_name` is provided (#677, #964)
- Fixed **auto-connect** triggering when the daemon is already running, preventing duplicate connections (#971)
- Fixed **Enter key press** not working by adding a text field to `keyDown` events (#972)
- Fixed **download command** to properly handle absolute paths and correctly click target elements (#970)
### Breaking Changes
- The `-C` / `--cursor` flag for `snapshot` is deprecated; cursor-interactive elements are now included by default and the flag has no additional effect (#968)
---
## v0.21.4
<p className="text-[#888] text-sm">March 20, 2026</p>
### Bug Fixes
- **Auth login readiness** - `agent-browser auth login` now navigates with `load`, waits for usable login form selectors, and uses staged username detection (targeted email/username selectors first, then broad text-input fallback). This reduces SPA timing failures, avoids false matches on unrelated text fields, and prevents `networkidle` hangs on pages with continuous background requests.
---
## v0.21.3
<p className="text-[#888] text-sm">March 20, 2026</p>
### Bug Fixes
- **WebSocket keepalive for remote browsers** - Added WebSocket Ping frames and TCP `SO_KEEPALIVE` to prevent CDP connections from being silently dropped by intermediate proxies during idle periods (#936)
- **XPath selector support** - Fixed element resolution to correctly handle the `xpath=` selector prefix (#908)
### Performance
- **Fast-path for identical snapshots** - Short-circuits the Myers diff algorithm when comparing a snapshot to itself, avoiding unnecessary computation in retry and loop workloads (#922)
---
## v0.21.2
<p className="text-[#888] text-sm">March 18, 2026</p>
### Bug Fixes
- **Deduplicate text content in snapshots** - Fixed an issue where duplicate text content appeared in page snapshots (#909)
- **Native mouse drag state** - Fixed incorrect raw native mouse drag state not being properly tracked across `down`, `move`, and `up` events (#872)
- **Chrome headless launch failures** - Fixed browser launch failures caused by the `--enable-unsafe-swiftshader` flag in Chrome headless mode (#915)
- **Origin-scoped `--headers` persistence** - Restored correct persistence of origin-scoped headers set via `--headers` across navigation commands (#894)
- **Relative URLs in WebSocket domain filter** - Fixed handling of relative URLs in the WebSocket domain filter script (#624)
---
## v0.21.1
<p className="text-[#888] text-sm">March 18, 2026</p>
### New Features
- **HAR 1.2 network capture** - Added commands to capture and export network traffic in HAR 1.2 format, including accurate request/response timing, headers, body sizes, and resource types sourced from Chrome DevTools Protocol events (#864)
- **Built-in `upgrade` command** - Added `agent-browser upgrade` to self-update the CLI; automatically detects your installation method (npm, Homebrew, or Cargo) and runs the appropriate update command (#898)
```bash
agent-browser upgrade
```
---
## v0.21.0
<p className="text-[#888] text-sm">March 17, 2026</p>
### New Commands
- **`batch`** -- Execute multiple commands in a single invocation. Pipe a JSON array of string arrays to stdin and receive results sequentially. Supports `--bail` to stop on first error and `--json` for structured output.
```bash
echo '[["open","example.com"],["snapshot"]]' | agent-browser batch --json
```
- **`network har start/stop`** -- Capture and export network traffic in HAR 1.2 format.
```bash
agent-browser network har start
# ... interact with the page ...
agent-browser network har stop ./trace.har
```
### New Features
- **iframe support** -- CLI interactions and snapshots now traverse into iframe content, enabling automation of cross-frame pages.
- **`--idle-timeout` flag** -- Automatically shut down the daemon after a period of inactivity. Accepts human-friendly formats such as `10s`, `3m`, `1h`, or raw milliseconds. Also available as `AGENT_BROWSER_IDLE_TIMEOUT_MS`.
- **Cursor-interactive elements in snapshots** -- Cursor-interactive elements are now embedded directly into the snapshot tree for richer context.
- **Brave Browser auto-connect** -- Auto-discovery of Brave Browser for CDP connections on macOS, Linux, and Windows.
- **linux-musl (Alpine) builds** -- Pre-built binaries for linux-musl targeting both x64 and arm64, enabling native support for Alpine Linux and other musl-based distributions.
- **WebSocket fallback for CDP discovery** -- When HTTP-based CDP endpoint discovery fails, the CLI now falls back to a WebSocket connection automatically.
### Improvements
- **`--full`/`-f` refactored to command-level flag** -- Moved from a global flag to a per-command flag for clearer scoping.
- **Enhanced Chrome launch** -- Added `--user-data-dir` support and configurable launch timeout for more reliable browser startup. Chrome now retries launching up to 3 times on transient startup failures.
- **Consecutive `--auto-connect` commands** -- Multiple consecutive auto-connect commands no longer require a full browser relaunch; external connections are correctly identified and reused.
- **Batched CDP calls** -- `snapshot -C` and `screenshot --annotate` now batch CDP calls instead of issuing sequential round-trips per element, preventing timeouts on high-latency WSS connections.
### Bug Fixes
- Fixed remote CDP (WSS) snapshot and screenshot hangs by removing WebSocket message/frame size limits
- Fixed Material Design `check`/`uncheck` falling back to JS `.click()` for overlay-based controls
- Fixed punctuation characters being dropped in the `type` command
- Fixed WebSocket streaming by keeping the StreamServer instance alive
- Filtered internal Chrome targets (`chrome://`, `devtools://`) from auto-connect discovery
- Fixed `snapshot --selector` scoping to the matched element's subtree
- Fixed network idle detection returning prematurely for cached pages
- Fixed daemon panic on broken stderr pipe during Chrome launch
- Fixed broadcast channel lag being treated as stream closure
- Fixed daemon liveness detection for PID namespace isolation (e.g. `unshare`)
- Fixed Ubuntu dependency install accidentally removing system packages
---
## v0.20.0
<p className="text-[#888] text-sm">March 13, 2026</p>
### Full Native Rust
agent-browser is now 100% native Rust. The Node.js/Playwright daemon has been completely removed -- no Node.js runtime or Playwright dependency is required to run the daemon. The Rust native daemon is now the only implementation.
<table>
<thead>
<tr>
<th>Metric</th>
<th>Node.js</th>
<th>Rust</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Cold start</td>
<td>1002ms</td>
<td>617ms</td>
<td>1.6x faster</td>
</tr>
<tr>
<td>Daemon memory</td>
<td>143 MB</td>
<td>8 MB</td>
<td>18x less</td>
</tr>
<tr>
<td>Install size</td>
<td>710 MB</td>
<td>7 MB</td>
<td>99x smaller</td>
</tr>
</tbody>
</table>
```bash
npm install -g agent-browser # 7 MB install
agent-browser install # download Chrome
agent-browser open example.com
agent-browser snapshot
```
### Improvements
- **Benchmarks** -- Added benchmark suite for comparing native vs Node.js daemon performance across cold start, warm start, memory, and install size.
- **Chromium installer hardened** -- Fixed zip path traversal vulnerability in Chrome for Testing installer.
### Bug Fixes
- Fixed `--headed false` flag not being respected in CLI
- Fixed "not found" error pattern in `to_ai_friendly_error` incorrectly catching non-element errors
- Fixed storage local key lookup parsing and text output
- Fixed Lightpanda engine launch with release binaries
- Hardened Lightpanda startup timeouts
---
## v0.19.0
<p className="text-[#888] text-sm">March 13, 2026</p>
### New Features
- **Browserless.io provider** -- Added browserless.io as a browser provider, supported in both Node.js and native daemon paths. Connect to remote Browserless instances using the `--provider browserless` flag or `AGENT_BROWSER_PROVIDER=browserless` environment variable.
```bash
export BROWSERLESS_API_KEY="your-api-key"
agent-browser --provider browserless open example.com
agent-browser --provider browserless screenshot ./page.png
```
- **`clipboard` command** -- Read from and write to the browser clipboard. Supports `read`, `write`, `copy` (simulates Ctrl+C), and `paste` (simulates Ctrl+V) operations.
```bash
agent-browser clipboard read
agent-browser clipboard write "Hello, World!"
agent-browser clipboard copy
agent-browser clipboard paste
```
- **Screenshot output configuration** -- New global flags for persistent screenshot settings: `--screenshot-dir`, `--screenshot-quality`, and `--screenshot-format`. Also available as environment variables `AGENT_BROWSER_SCREENSHOT_DIR`, `AGENT_BROWSER_SCREENSHOT_QUALITY`, and `AGENT_BROWSER_SCREENSHOT_FORMAT`.
```bash
agent-browser screenshot --screenshot-dir ./shots
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
```
### Bug Fixes
- Fixed `wait --text` not working in native daemon path
- Fixed `BrowserManager.navigate()` and package entry point
- Fixed extensions not being loaded from `config.json`
- Fixed scroll on page load
- Fixed HTML retrieval by using `browser.getLocator()` for selector operations
---
## v0.18.0
<p className="text-[#888] text-sm">March 12, 2026</p>
### New Features
- **`inspect` command** -- Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Agent commands continue to work while DevTools is open.
```bash
agent-browser open example.com
agent-browser inspect # opens DevTools in your browser
agent-browser click "Submit" # commands still work while DevTools is open
```
- **`get cdp-url` subcommand** -- Retrieve the Chrome DevTools Protocol WebSocket URL for the active page, useful for connecting external debugging tools.
```bash
agent-browser get cdp-url
```
- **Screenshot annotate** -- The `--annotate` flag overlays numbered labels on interactive elements in screenshots.
### Improvements
- **KERNEL_API_KEY now optional** -- External credential injection no longer requires `KERNEL_API_KEY` to be set, making it easier to use Kernel with pre-configured environments.
- **Browserbase simplified** -- Removed the `BROWSERBASE_PROJECT_ID` requirement, reducing setup friction for Browserbase users.
### Bug Fixes
- Fixed Browserbase API using incorrect endpoint to release sessions
- Fixed CDP connect paths using hardcoded 10s timeout instead of the configurable default timeout
- Fixed lone Unicode surrogates causing errors by sanitizing with `toWellFormed()`
- Fixed CDP connection failure on IPv6-first systems
- Fixed recordings not inheriting the current viewport settings
---
## v0.17.1
<p className="text-[#888] text-sm">March 9, 2026</p>
### Improvements
- **Viewport scale factor** -- Added support for device scale factor (retina display) in the viewport command via an optional `scale` parameter.
- **Webview target support** -- Added webview target type support for better Electron application compatibility. The pages list now includes target type information.
---
## v0.17.0
<p className="text-[#888] text-sm">March 8, 2026</p>
### New Features
- **Lightpanda browser engine support** -- Added `--engine <name>` flag to select the browser engine (`chrome` by default, or `lightpanda`), implying `--native` mode. Configurable via `AGENT_BROWSER_ENGINE` environment variable.
```bash
agent-browser --engine lightpanda open example.com
```
- **Dialog dismiss command** -- Added support for `dismiss` subcommand in dialog command parsing.
### Improvements
- **Daemon startup error reporting** -- Daemon startup errors are now surfaced directly instead of showing an opaque timeout message.
- **CDP port discovery** -- Replaced hand-rolled HTTP client with `reqwest` for more reliable CDP port discovery.
- **Chrome extensions** -- Extensions now load correctly by forcing headed mode when extensions are present.
- **Google Translate bar suppression** -- Suppressed the Google Translate bar in native headless mode to avoid interference.
- **Auth cookie persistence** -- Auth cookies are now persisted on browser close in native mode.
### Bug Fixes
- Fixed native auth login failing due to incompatible encryption format.
### Performance
- Added benchmarks to the CLI codebase.
---
## v0.16.0
<p className="text-[#888] text-sm">March 3, 2026</p>
### New Features
- **Native Rust daemon (experimental).** A pure Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating Node.js and Playwright dependencies entirely. Enable with `--native`, `AGENT_BROWSER_NATIVE=1`, or `"native": true` in your config file. Supports 150+ commands with full parity to the default Node.js daemon.
```bash
# Via flag
agent-browser --native open example.com
# Via environment variable
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
Or add to `agent-browser.json`:
```json
{"native": true}
```
### Architecture
<table>
<thead>
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
</thead>
<tbody>
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
</tbody>
</table>
### What's Supported
All core commands work in native mode: navigation, interaction (click, fill, type, press, hover, scroll, drag), observation (snapshot, screenshot, eval), state management (cookies, storage, state save/load), tabs, emulation (viewport, device, timezone, locale, geolocation), streaming, diffing, recording, and profiling.
The native daemon also includes a WebDriver backend for Safari and iOS support.
### Known Limitations
- Firefox and WebKit are not yet supported (Chromium and Safari only)
- Playwright trace format is not available (uses Chrome's built-in tracing)
- HAR export is not available
- Network route interception uses CDP Fetch domain instead of Playwright's route API
- The native and Node.js daemons share the same session socket. Use `agent-browser close` before switching between modes.
See the [Native Mode](/native-mode) page for full details.
---
## v0.15.0
<p className="text-[#888] text-sm">February 25, 2026</p>
### New Features
- **Authentication vault** -- Store credentials locally (always AES-256-GCM encrypted) and reference them by name. The LLM never sees passwords. Commands: `auth save`, `auth login`, `auth list`, `auth show`, `auth delete`. Passwords can be piped via stdin (`--password-stdin`) to avoid shell history exposure.
- **Content boundary markers** -- `--content-boundaries` wraps page-sourced output in structural delimiters with a per-process CSPRNG nonce, so LLMs can distinguish trusted tool output from untrusted page content. In `--json` mode, a `_boundary` object is injected with `nonce` and `origin` fields.
- **Domain allowlist** -- `--allowed-domains` restricts navigation, sub-resource requests, WebSocket connections, and EventSource streams to trusted domains. Supports exact match and wildcard prefix patterns (e.g., `*.example.com`).
- **Action policy** -- `--action-policy` gates actions using a static JSON policy file with `allow`/`deny` lists across 13 action categories. Auth vault operations bypass policy enforcement.
- **Action confirmation** -- `--confirm-actions` requires explicit approval for sensitive action categories. New `confirm` and `deny` commands for orchestrator use. `--confirm-interactive` enables human-in-the-loop terminal prompts (auto-denies if stdin is not a TTY). Pending confirmations auto-deny after 60 seconds.
- **Output length limits** -- `--max-output` truncates large page outputs to prevent LLM context flooding.
- **`--download-path` option** -- Set a default download directory via flag, `AGENT_BROWSER_DOWNLOAD_PATH` env var, or `downloadPath` config key. Without it, downloads go to a temporary directory deleted when the browser closes.
- **`--selector` flag for scroll** -- Scroll within a specific container element instead of the page: `agent-browser scroll down 500 --selector "div.scroll-container"`
```bash
# Auth vault
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
agent-browser auth login github
# Security flags
agent-browser --content-boundaries --allowed-domains "example.com,*.example.com" --max-output 50000 open https://example.com
# Download path
agent-browser --download-path ./downloads open https://example.com
# Scroll within container
agent-browser scroll down 500 --selector "div.content"
```
### Environment Variables
Six new environment variables for security configuration: `AGENT_BROWSER_CONTENT_BOUNDARIES`, `AGENT_BROWSER_MAX_OUTPUT`, `AGENT_BROWSER_ALLOWED_DOMAINS`, `AGENT_BROWSER_ACTION_POLICY`, `AGENT_BROWSER_CONFIRM_ACTIONS`, `AGENT_BROWSER_CONFIRM_INTERACTIVE`.
---
## v0.14.0
<p className="text-[#888] text-sm">February 23, 2026</p>
### New Features
- **`keyboard` command** -- Type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector (`keyboard type`, `keyboard inserttext`).
- **`--color-scheme` flag** -- Persistent dark/light mode preference across browser sessions via flag or `AGENT_BROWSER_COLOR_SCHEME` env var.
```bash
agent-browser keyboard type "Hello world"
agent-browser keyboard inserttext "pasted text"
agent-browser --color-scheme dark open https://example.com
```
### Bug Fixes
- Fixed IPC EAGAIN errors (os error 35/11) with backpressure-aware socket writes, command serialization, and lowered default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`).
- Fixed remote debugging (CDP) reconnection.
- Fixed state load failing when no browser is running.
- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI.
---
## v0.13.0
<p className="text-[#888] text-sm">February 20, 2026</p>
### New Features
- **Diff commands** -- Compare snapshots, screenshots, and URLs between page states. Run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison.
```bash
agent-browser diff snapshot
agent-browser diff screenshot --baseline before.png
agent-browser diff url https://staging.example.com https://prod.example.com
```
---
## v0.12.0
<p className="text-[#888] text-sm">February 18, 2026</p>
### New Features
- **Annotated screenshots** -- `--annotate` flag overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. Enables multimodal AI models to reason about visual layout while using the same `@eN` refs for subsequent interactions. Also settable via `AGENT_BROWSER_ANNOTATE` env var.
```bash
agent-browser screenshot --annotate
```
---
## v0.11.1
<p className="text-[#888] text-sm">February 18, 2026</p>
### Documentation
- Added documentation for command chaining with `&&` across README, CLI help output, docs, and skill files.
---
## v0.11.0
<p className="text-[#888] text-sm">February 17, 2026</p>
### New Features
- **Configuration file support** -- Automatic loading from user (`~/.agent-browser/config.json`) and project (`./agent-browser.json`) directories with priority-based merging.
- **Profiler commands** -- Chrome DevTools profiling with `profiler start` and `profiler stop`.
- **Browser extension loading** -- `--extension` flag to load browser extensions.
- **Storage state management** -- `state save` and `state load` commands for auth state persistence.
- **iOS device emulation** -- `--device` flag for device emulation.
- **Enhanced click** -- `--new-tab` option for click commands.
- **Enhanced find** -- Additional actions and filtering options.
- **CDP WebSocket URLs** -- `--cdp` now accepts WebSocket URLs in addition to ports.
---
## v0.10.0
<p className="text-[#888] text-sm">February 2026</p>
<p className="text-[#888] text-sm">February 13, 2026</p>
### New Features
@@ -29,7 +667,7 @@ agent-browser state clear myapp
## v0.9.4
<p className="text-[#888] text-sm">February 2026</p>
<p className="text-[#888] text-sm">February 13, 2026</p>
### Bug Fixes
@@ -39,7 +677,7 @@ agent-browser state clear myapp
## v0.9.3
<p className="text-[#888] text-sm">February 2026</p>
<p className="text-[#888] text-sm">February 12, 2026</p>
### Improvements
@@ -50,7 +688,7 @@ agent-browser state clear myapp
## v0.9.2
<p className="text-[#888] text-sm">February 2026</p>
<p className="text-[#888] text-sm">February 10, 2026</p>
### Improvements
@@ -62,7 +700,7 @@ agent-browser state clear myapp
## v0.9.1
<p className="text-[#888] text-sm">February 2026</p>
<p className="text-[#888] text-sm">February 5, 2026</p>
### New Features
@@ -78,7 +716,7 @@ agent-browser snapshot -C
## v0.9.0
<p className="text-[#888] text-sm">February 2026</p>
<p className="text-[#888] text-sm">February 3, 2026</p>
### New Features
@@ -100,7 +738,7 @@ agent-browser swipe up
## v0.8.10
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">February 2, 2026</p>
### Improvements
@@ -111,7 +749,7 @@ agent-browser swipe up
## v0.8.9
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">February 2, 2026</p>
### Improvements
@@ -121,7 +759,7 @@ agent-browser swipe up
## v0.8.8
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">February 2, 2026</p>
### Improvements
@@ -132,7 +770,7 @@ agent-browser swipe up
## v0.8.7
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">February 2, 2026</p>
### Bug Fixes
@@ -144,7 +782,7 @@ agent-browser swipe up
## v0.8.6
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 31, 2026</p>
### Bug Fixes
@@ -155,7 +793,7 @@ agent-browser swipe up
## v0.8.5
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 29, 2026</p>
### Bug Fixes
@@ -166,7 +804,7 @@ agent-browser swipe up
## v0.8.4
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 27, 2026</p>
### Bug Fixes
@@ -176,7 +814,7 @@ agent-browser swipe up
## v0.8.3
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 27, 2026</p>
### Improvements
@@ -188,7 +826,7 @@ agent-browser swipe up
## v0.8.2
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 26, 2026</p>
### Bug Fixes
@@ -199,7 +837,7 @@ agent-browser swipe up
## v0.8.1
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 26, 2026</p>
### Improvements
@@ -210,7 +848,7 @@ agent-browser swipe up
## v0.8.0
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 26, 2026</p>
### New Features
@@ -259,7 +897,7 @@ agent-browser cookies set token "xyz" --domain .example.com --path /api --expire
## v0.7.1
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 23, 2026</p>
### Bug Fixes
@@ -269,7 +907,7 @@ agent-browser cookies set token "xyz" --domain .example.com --path /api --expire
## v0.7.0
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 23, 2026</p>
### New Features
@@ -333,7 +971,7 @@ agent-browser --proxy-bypass "localhost,*.internal" open example.com
## v0.6.0
<p className="text-[#888] text-sm">January 2026</p>
<p className="text-[#888] text-sm">January 18, 2026</p>
### New Features
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("commands");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+170 -22
View File
@@ -1,7 +1,3 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("commands")
# Commands
## Core
@@ -11,9 +7,9 @@ agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser click <sel> # Click element (--new-tab to open in new tab)
agent-browser dblclick <sel> # Double-click
agent-browser fill <sel> <text> # Clear and fill
agent-browser type <sel> <text> [--delay <ms>] # Type into element
agent-browser type <sel> <text> # Type into element
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
agent-browser keyboard type <text> [--delay <ms>] # Type at current focus (no selector needed)
agent-browser keyboard type <text> # Type at current focus (no selector needed)
agent-browser keyboard inserttext <text> # Insert text without key events
agent-browser keydown <key> # Hold key down
agent-browser keyup <key> # Release key
@@ -22,24 +18,23 @@ agent-browser focus <sel> # Focus element
agent-browser select <sel> <val> # Select dropdown option
agent-browser check <sel> # Check checkbox
agent-browser uncheck <sel> # Uncheck checkbox
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
agent-browser scroll <dir> [px] # Scroll (up/down/left/right, --selector <sel>)
agent-browser scrollintoview <sel> # Scroll element into view
agent-browser drag <src> <dst> # Drag and drop
agent-browser upload <sel> <files> # Upload files
agent-browser screenshot [path] # Screenshot (--full for full page)
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser screenshot --screenshot-dir ./shots # Save to custom directory
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
agent-browser pdf <path> # Save page as PDF
agent-browser snapshot # Accessibility tree with refs
agent-browser eval <js> # Run JavaScript
agent-browser connect <port|url> # Connect to browser via CDP
agent-browser --version # Show CLI version
agent-browser stream enable [--port <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)
```
Fork builds print dual-version metadata with `--version`:
```bash
agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1)
agent-browser close --all # Close all active sessions
```
## Get info
@@ -51,6 +46,7 @@ agent-browser get value <sel> # Get input value
agent-browser get attr <sel> <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
agent-browser get count <sel> # Count matching elements
agent-browser get box <sel> # Get bounding box
agent-browser get styles <sel> # Get computed styles
@@ -102,12 +98,13 @@ agent-browser find nth 2 ".card" hover
```bash
agent-browser wait <selector> # Wait for element
agent-browser wait <ms> # Wait for time
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
agent-browser wait --text "Welcome" # Wait for text
agent-browser wait --text "Welcome" # Wait for text (substring match)
agent-browser wait --url "**/dash" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for load state
agent-browser wait --fn "condition" # Wait for JS condition
agent-browser wait --download [path] # Wait for download
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
```
## Downloads
@@ -117,6 +114,8 @@ agent-browser download <sel> <path> # Click element to trigger download
agent-browser wait --download [path] # Wait for any download to complete
```
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
## Mouse
```bash
@@ -126,10 +125,19 @@ agent-browser mouse up [button] # Release button
agent-browser mouse wheel <dy> [dx] # Scroll wheel
```
## 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)
```
## Settings
```bash
agent-browser set viewport <w> <h> # Set viewport size
agent-browser set viewport <w> <h> [scale] # Set viewport size (scale for retina, e.g. 2)
agent-browser set device <name> # Emulate device ("iPhone 14")
agent-browser set geo <lat> <lng> # Set geolocation
agent-browser set offline [on|off] # Toggle offline mode
@@ -169,6 +177,12 @@ agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --clear # Clear request log
agent-browser network requests --filter <pat> # Filter by URL pattern
agent-browser network requests --type xhr,fetch # Filter by resource type
agent-browser network requests --method POST # Filter by HTTP method
agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499)
agent-browser network request <requestId> # View full request/response detail
agent-browser network har start # Start HAR recording
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
```
## Tabs & frames
@@ -179,17 +193,58 @@ agent-browser tab new [url] # New tab
agent-browser tab <n> # Switch to tab
agent-browser tab close [n] # Close tab
agent-browser window new # Open new browser window
agent-browser frame <sel> # Switch to iframe
agent-browser frame <sel> # Switch to iframe by CSS selector
agent-browser frame @e3 # Switch to iframe by element ref
agent-browser frame main # Back to main frame
```
### Iframe support
Iframes are detected automatically during snapshots. `Iframe` nodes are resolved and their content is
inlined beneath the iframe element in the snapshot output. Refs assigned to elements inside iframes carry
frame context, so `click`, `fill`, and other interactions work without manually switching frames.
```bash
agent-browser snapshot -i
# @e3 [Iframe] "payment-frame"
# @e4 [input] "Card number"
# @e5 [button] "Pay"
# Interact directly using refs — no frame switch needed
agent-browser fill @e4 "4111111111111111"
agent-browser click @e5
# Or switch frame context for scoped snapshots
agent-browser frame @e3
agent-browser snapshot -i # Only elements inside that iframe
agent-browser frame main # Return to main frame
```
The `frame` command accepts element refs (`@e3`), CSS selectors (`"#my-iframe"`), or frame name/URL.
## Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog (with optional prompt text)
agent-browser dialog dismiss # Dismiss dialog
agent-browser dialog status # Check if a dialog is currently open
```
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` (or `AGENT_BROWSER_NO_AUTO_DIALOG=1`) to disable automatic handling.
When a JavaScript dialog (`alert`, `confirm`, `prompt`) is pending, all command responses include a `warning` field with the dialog type and message.
## Streaming
```bash
agent-browser stream enable # Start runtime WebSocket streaming on an auto-selected port
agent-browser stream enable --port 9223 # Bind a specific localhost port
agent-browser stream status # Show enabled state, port, browser connection, screencasting
agent-browser stream disable # Stop runtime streaming and remove the .stream metadata file
```
Streaming is enabled automatically for all sessions. Use these commands to check status, re-enable on a specific port, or disable streaming.
## Debug
```bash
@@ -201,10 +256,57 @@ agent-browser record start <path> # Start video recording (WebM)
agent-browser record stop # Stop and save video
agent-browser record restart <path> # Stop current and start new recording
agent-browser console # View console messages
agent-browser console --json # JSON output with raw CDP args
agent-browser console --clear # Clear console log
agent-browser errors # View page errors
agent-browser errors --clear # Clear error log
agent-browser highlight <sel> # Highlight element
agent-browser inspect # Open Chrome DevTools for the active page
```
## Auth vault
```bash
agent-browser auth save <name> [opts] # Save auth profile
agent-browser auth login <name> # Login using saved credentials
agent-browser auth list # List saved profiles (names and URLs only)
agent-browser auth show <name> # Show profile metadata (no passwords)
agent-browser auth delete <name> # Delete a saved profile
```
Save options:
- `--url <url>` -- login page URL (required)
- `--username <user>` -- username (required)
- `--password <pass>` -- password (required unless `--password-stdin`)
- `--password-stdin` -- read password from stdin (recommended to avoid shell history exposure)
- `--username-selector <sel>` -- custom CSS selector for username field
- `--password-selector <sel>` -- custom CSS selector for password field
- `--submit-selector <sel>` -- custom CSS selector for submit button
`auth login` navigates with `load` and then waits for the username/password/submit selectors to appear before interacting. This improves reliability on SPA login pages where fields render after initial page load.
```bash
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
agent-browser auth login github
agent-browser auth list
```
## Confirmation
When `--confirm-actions` is set, certain action categories return a `confirmation_required` response instead of executing immediately. Use `confirm` or `deny` to approve or reject the action.
```bash
agent-browser confirm <confirmation-id> # Approve a pending action
agent-browser deny <confirmation-id> # Deny a pending action
```
Pending confirmations auto-deny after 60 seconds.
```bash
agent-browser --confirm-actions eval,download eval "document.title"
# Returns confirmation_required with ID
agent-browser confirm c_8f3a1234
```
## State management
@@ -227,6 +329,15 @@ agent-browser session # Show current session name
agent-browser session list # List active sessions
```
## Dashboard
```bash
agent-browser dashboard [start] # Start the dashboard server (default port: 4848)
agent-browser dashboard start --port <n> # Start on a specific port
agent-browser dashboard stop # Stop the dashboard server
agent-browser dashboard install # Install the dashboard files
```
## Navigation
```bash
@@ -240,6 +351,7 @@ agent-browser reload # Reload page
```bash
--session <name> # Isolated browser session
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
--profile <path> # Persistent browser profile directory
--state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable
@@ -250,18 +362,54 @@ agent-browser reload # Reload page
--proxy-bypass <hosts> # Hosts to bypass proxy
--ignore-https-errors # Ignore HTTPS certificate errors
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
--stealth # Stealth mode (always on by default)
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse, browserless)
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
--json # JSON output (for scripts)
--full, -f # Full page screenshot
--annotate # Annotated screenshot with numbered element labels
--screenshot-dir <path> # Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR)
--screenshot-quality <n> # JPEG quality 0-100 (or AGENT_BROWSER_SCREENSHOT_QUALITY)
--screenshot-format <fmt> # Format: png (default), jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT)
--headed # Show browser window (not headless)
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
--auto-connect # Auto-discover and connect to running Chrome
--debug # Debug output (includes stealth connection type + capabilities)
--color-scheme <scheme> # Color scheme: dark, light, no-preference
--download-path <path> # Default download directory
--content-boundaries # Wrap page output in boundary markers for LLM safety
--max-output <chars> # Truncate page output to N characters
--allowed-domains <list> # Comma-separated allowed domain patterns
--action-policy <path> # Path to action policy JSON file
--confirm-actions <list> # Action categories requiring confirmation
--confirm-interactive # Interactive confirmation prompts (auto-denies if stdin is not a TTY)
--config <path> # Use a custom config file
--debug # Debug output
```
## Batch execution
Execute multiple commands in a single invocation by piping a JSON array of string arrays to `batch`:
```bash
echo '[
["open", "https://example.com"],
["snapshot", "-i"],
["click", "@e1"],
["screenshot", "result.png"]
]' | agent-browser batch --json
# Stop on first error
agent-browser batch --bail < commands.json
```
<table>
<thead>
<tr><th>Option</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>--bail</code></td><td>Stop on first error (default: continue all commands)</td></tr>
<tr><td><code>--json</code></td><td>Output results as a JSON array</td></tr>
</tbody>
</table>
## Command chaining
Chain commands with `&&` in a single shell invocation. The browser persists via a background daemon, so chaining works naturally and is more efficient than separate calls:
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("configuration");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+38 -10
View File
@@ -1,13 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("configuration")
# Configuration
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
In this fork, default launch behavior requires a resident browser at `localhost:9333` (CDP). If unavailable, commands fail fast instead of launching a managed browser.
## Config File Locations
agent-browser checks two locations, merged in priority order:
@@ -39,6 +33,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data",
"userAgent": "my-agent/1.0",
"ignoreHttpsErrors": true
}
@@ -61,6 +56,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
@@ -73,6 +69,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
<tr><td><code>colorScheme</code></td><td><code>--color-scheme</code></td><td>string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
<tr><td><code>downloadPath</code></td><td><code>--download-path</code></td><td>string</td></tr>
<tr><td><code>contentBoundaries</code></td><td><code>--content-boundaries</code></td><td>boolean</td></tr>
<tr><td><code>maxOutput</code></td><td><code>--max-output</code></td><td>number</td></tr>
<tr><td><code>allowedDomains</code></td><td><code>--allowed-domains</code></td><td>string[]</td></tr>
<tr><td><code>actionPolicy</code></td><td><code>--action-policy</code></td><td>string</td></tr>
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
<tr><td><code>engine</code></td><td><code>--engine</code></td><td>string (<code>chrome</code>, <code>lightpanda</code>)</td></tr>
<tr><td><code>noAutoDialog</code></td><td><code>--no-auto-dialog</code></td><td>boolean</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
</tbody>
</table>
@@ -84,7 +89,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
```json
{
"headed": true,
"sessionName": "local-dev"
"profile": "./browser-data"
}
```
@@ -116,6 +121,17 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
}
```
### AI Agent Security
```json
{
"contentBoundaries": true,
"maxOutput": 50000,
"allowedDomains": ["your-app.com", "*.your-app.com"],
"actionPolicy": "./policy.json"
}
```
## Overriding Boolean Options
Boolean flags accept an optional `true`/`false` value to override config settings:
@@ -131,7 +147,7 @@ agent-browser --headed open example.com # same as --headed true
agent-browser --headed true open example.com # explicit
```
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`.
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`.
## Extensions Merging
@@ -151,14 +167,26 @@ These environment variables configure additional daemon and runtime behavior:
<tr><td><code>AGENT_BROWSER_AUTO_CONNECT</code></td><td>Auto-discover and connect to a running Chrome instance.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_EXTENSIONS</code></td><td>Comma-separated browser extension paths. Extensions work in both headed and headless mode.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_HEADED</code></td><td>Show browser window instead of running headless (<code>1</code> to enable).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Override the WebSocket streaming port. By default, an OS-assigned port is used. Set this to bind to a specific port (e.g., <code>9223</code>).</td><td>OS-assigned</td></tr>
<tr><td><code>AGENT_BROWSER_IDLE_TIMEOUT_MS</code></td><td>Auto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers for LLM safety.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output (truncates beyond limit).</td><td>(unlimited)</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns (e.g., <code>example.com,*.example.com</code>).</td><td>(unrestricted)</td></tr>
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>.</td><td><code>chrome</code></td></tr>
<tr><td><code>AGENT_BROWSER_NO_AUTO_DIALOG</code></td><td>Disable automatic dismissal of <code>alert</code>/<code>beforeunload</code> dialogs.</td><td>(disabled)</td></tr>
</tbody>
</table>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("dashboard");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+137
View File
@@ -0,0 +1,137 @@
# Observability Dashboard
Monitor agent-browser sessions in real time with a local web dashboard showing a live browser viewport and command activity feed.
## Install
Download the dashboard once:
```bash
agent-browser dashboard install
```
This downloads the dashboard to `~/.agent-browser/dashboard/` and is served directly by the daemon when streaming is enabled.
## Usage
Start the dashboard server and open any session -- it appears automatically:
```bash
agent-browser dashboard start
agent-browser open example.com
```
Then open `http://localhost:4848` in your browser to see the live dashboard.
All sessions automatically stream to the dashboard. No extra flags are needed.
### Custom stream port
By default each session binds its WebSocket stream server to an OS-assigned port. To use a specific port, set the `AGENT_BROWSER_STREAM_PORT` environment variable:
```bash
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
```
You can also use the runtime commands to control streaming on a running session:
```bash
agent-browser stream enable --port 9223
agent-browser stream status
agent-browser stream disable
```
## Dashboard features
The dashboard is a single-page web app with three areas:
<table>
<thead>
<tr>
<th>Area</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Live viewport</strong></td>
<td>Real-time JPEG frames from the browser, rendered to a canvas element</td>
</tr>
<tr>
<td><strong>Activity feed</strong></td>
<td>Chronological stream of commands, results, and console messages with expandable details</td>
</tr>
<tr>
<td><strong>Session creation</strong></td>
<td>Create new sessions from the dashboard with local engines (Chrome, Lightpanda) or cloud providers (AgentCore, Browserbase, Browserless, Browser Use, Kernel)</td>
</tr>
<tr>
<td><strong>Status bar</strong></td>
<td>Connection status, viewport dimensions, and WebSocket endpoint</td>
</tr>
</tbody>
</table>
## WebSocket protocol
The dashboard connects to the same WebSocket endpoint used by [Streaming](/streaming), with additional message types for observability:
### Command events
Sent when a command begins executing:
```json
{
"type": "command",
"action": "click",
"id": "r123",
"params": { "selector": "@e5" },
"timestamp": 1711367000000
}
```
### Result events
Sent when a command finishes:
```json
{
"type": "result",
"id": "r123",
"action": "click",
"success": true,
"data": {},
"duration_ms": 45,
"timestamp": 1711367000045
}
```
### Console events
Sent when the browser logs to the console:
```json
{
"type": "console",
"level": "log",
"text": "Page loaded",
"args": [{"type": "string", "value": "Page loaded"}],
"timestamp": 1711367000100
}
```
The `args` array contains the raw CDP `Runtime.consoleAPICalled` arguments for programmatic access. Object arguments include preview data (e.g. `{userId: "abc", count: 42}` instead of `"Object"`).
These are in addition to the existing `frame`, `status`, and `error` message types documented on the [Streaming](/streaming) page.
## Architecture
The dashboard is a Next.js static export (`output: 'export'`) that produces plain HTML, CSS, and JS. It lives at `packages/dashboard/` in the monorepo and is built with:
```bash
pnpm build:dashboard
```
The built files are served by the daemon's stream server on the same port used for WebSocket connections. Plain HTTP requests serve the dashboard, while WebSocket upgrade requests are handled as before.
When the dashboard is not installed, visiting the HTTP endpoint shows instructions to run `agent-browser dashboard install`.
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("diffing");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
-4
View File
@@ -1,7 +1,3 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("diffing")
import { DiffDemo } from "@/components/diff-demo"
# Diffing
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("engines/chrome");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+104
View File
@@ -0,0 +1,104 @@
# Chrome
Chrome (and Chromium) is the default browser engine. agent-browser discovers, launches, and manages the Chrome process automatically via the Chrome DevTools Protocol (CDP).
## Binary Discovery
When no `--executable-path` is provided, agent-browser searches for Chrome in this order:
<table>
<thead>
<tr><th>Platform</th><th>Locations checked</th></tr>
</thead>
<tbody>
<tr>
<td>macOS</td>
<td>
<code>/Applications/Google Chrome.app</code>,
<code>/Applications/Google Chrome Canary.app</code>,
<code>/Applications/Chromium.app</code>,
<code>/Applications/Brave Browser.app</code>,
Puppeteer cache (<code>~/.cache/puppeteer/chrome/</code> or <code>PUPPETEER_CACHE_DIR</code>),
Chrome for Testing cache
</td>
</tr>
<tr>
<td>Linux</td>
<td>
<code>google-chrome</code>,
<code>google-chrome-stable</code>,
<code>chromium-browser</code>,
<code>chromium</code> in PATH,
Puppeteer cache (<code>~/.cache/puppeteer/chrome/</code> or <code>PUPPETEER_CACHE_DIR</code>),
Chrome for Testing cache
</td>
</tr>
<tr>
<td>Windows</td>
<td>
<code>%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe</code>,
<code>C:\Program Files\Google\Chrome\Application\chrome.exe</code>,
<code>C:\Program Files (x86)\...\chrome.exe</code>
</td>
</tr>
</tbody>
</table>
If Chrome is not found, run `agent-browser install` to download Chrome from Chrome for Testing.
## Usage
Chrome is the default engine -- no `--engine` flag is needed:
```bash
agent-browser open example.com
```
To be explicit:
```bash
agent-browser --engine chrome open example.com
```
## Custom Binary
Point to any Chromium-based browser with `--executable-path`:
```bash
agent-browser --executable-path /path/to/chromium open example.com
```
Or via environment variable:
```bash
export AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium
agent-browser open example.com
```
## Chrome-Specific Features
These features are available only with Chrome:
<table>
<thead>
<tr><th>Feature</th><th>Flag</th></tr>
</thead>
<tbody>
<tr><td>Browser extensions</td><td><code>--extension &lt;path&gt;</code></td></tr>
<tr><td>Persistent profiles</td><td><code>--profile &lt;path&gt;</code> (sets Chrome's <code>--user-data-dir</code>)</td></tr>
<tr><td>Storage state</td><td><code>--state &lt;path&gt;</code></td></tr>
<tr><td>File URL access</td><td><code>--allow-file-access</code></td></tr>
<tr><td>Headed mode</td><td><code>--headed</code></td></tr>
<tr><td>Custom launch args</td><td><code>--args &lt;args&gt;</code></td></tr>
</tbody>
</table>
## Containers and CI
In Docker, CI runners, or other sandboxed environments, Chrome's user namespace sandbox may need to be disabled:
```bash
agent-browser --args "--no-sandbox" open example.com
```
agent-browser automatically adds `--no-sandbox` when it detects a container environment (Docker, Podman, running as root).
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("engines/lightpanda");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+93
View File
@@ -0,0 +1,93 @@
# Lightpanda
[Lightpanda](https://lightpanda.io/) is a headless browser engine built from scratch in Zig for machines. It starts instantly, uses 10x less memory than Chrome, and executes 10x faster.
agent-browser manages Lightpanda the same way it manages Chrome -- spawning the process, connecting via CDP, and shutting it down. All downstream commands (snapshot, click, fill, screenshot, etc.) work through the same CDP protocol path.
## Installation
Install the Lightpanda binary before using it with agent-browser:
<table>
<thead>
<tr><th>Platform</th><th>Command</th></tr>
</thead>
<tbody>
<tr>
<td>macOS (Apple Silicon)</td>
<td><code>curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && chmod a+x ./lightpanda</code></td>
</tr>
<tr>
<td>Linux (x86_64)</td>
<td><code>curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && chmod a+x ./lightpanda</code></td>
</tr>
</tbody>
</table>
Move the binary somewhere in your `PATH` (e.g. `/usr/local/bin/lightpanda` or `~/.local/bin/lightpanda`).
See the [Lightpanda installation docs](https://lightpanda.io/docs/open-source/installation) for more options.
## Usage
Use the `--engine` flag to select Lightpanda:
```bash
agent-browser --engine lightpanda open example.com
agent-browser --engine lightpanda snapshot
agent-browser --engine lightpanda screenshot
```
Or set it as the default via environment variable:
```bash
export AGENT_BROWSER_ENGINE=lightpanda
agent-browser open example.com
```
Or in your `agent-browser.json` config:
```json
{
"engine": "lightpanda"
}
```
## Custom Binary Path
If the `lightpanda` binary is not in your `PATH`, use `--executable-path`:
```bash
agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com
```
## Differences from Chrome
Lightpanda is a purpose-built headless engine. Some Chrome-specific features are not available:
<table>
<thead>
<tr><th>Feature</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td>Extensions (<code>--extension</code>)</td><td>Not supported</td></tr>
<tr><td>Persistent profiles (<code>--profile</code>)</td><td>Not supported</td></tr>
<tr><td>Storage state (<code>--state</code>)</td><td>Not supported</td></tr>
<tr><td>File access (<code>--allow-file-access</code>)</td><td>Not supported</td></tr>
<tr><td>Headed mode (<code>--headed</code>)</td><td>Not applicable (headless only)</td></tr>
<tr><td>Screenshots</td><td>Depends on Lightpanda CDP support</td></tr>
</tbody>
</table>
agent-browser returns a clear error if you combine `--engine lightpanda` with unsupported flags.
## When to Use Lightpanda
Lightpanda is a good fit for:
- Fast web scraping and data extraction
- AI agent workflows where speed and low memory matter
- CI/CD environments with constrained resources
- High-volume parallel automation
Use Chrome when you need full browser fidelity, extensions, or persistent profiles.
+178 -115
View File
@@ -1,117 +1,115 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "tailwindcss-animate";
@source "../../node_modules/streamdown/dist/index.js";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", "Cascadia Mono", "Segoe UI Mono", Menlo, Consolas, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-sidebar: var(--sidebar);
}
:root {
--radius: 0.5rem;
--background: oklch(1.0 0 0);
--foreground: oklch(0.1 0 0);
--card: oklch(0.98 0 0);
--card-foreground: oklch(0.1 0 0);
--popover: oklch(0.98 0 0);
--popover-foreground: oklch(0.1 0 0);
--primary: oklch(0.1 0 0);
--primary-foreground: oklch(1.0 0 0);
--secondary: oklch(0.92 0 0);
--secondary-foreground: oklch(0.1 0 0);
--muted: oklch(0.92 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.92 0 0);
--accent-foreground: oklch(0.1 0 0);
--destructive: oklch(0.55 0.2 25);
--destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.85 0 0);
--input: oklch(0.85 0 0);
--ring: oklch(0.6 0 0);
--chat-bg: oklch(0.95 0 0);
--background: #fff;
--foreground: #171717;
--border: #e5e5e5;
--muted: #f5f5f5;
--muted-foreground: #737373;
--primary: #171717;
--primary-foreground: #fff;
--sidebar: #f5f5f5;
}
.dark {
--background: oklch(0.0 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.08 0 0);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.08 0 0);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.98 0 0);
--primary-foreground: oklch(0.0 0 0);
--secondary: oklch(0.15 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.15 0 0);
--muted-foreground: oklch(0.6 0 0);
--accent: oklch(0.15 0 0);
--accent-foreground: oklch(0.1 0 0);
--destructive: oklch(0.65 0.2 25);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.25 0 0);
--input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0);
--chat-bg: oklch(0.25 0 0);
--background: #0a0a0a;
--foreground: #f5f5f5;
--border: #262626;
--muted: #262626;
--muted-foreground: #a3a3a3;
--primary: #f5f5f5;
--primary-foreground: #0a0a0a;
--sidebar: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
}
* {
border-color: var(--border);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist), system-ui, sans-serif;
}
/* Hide page scrollbar */
html {
scrollbar-width: none;
scroll-padding-top: 4rem;
}
html::-webkit-scrollbar {
display: none;
::selection {
background-color: #000;
color: #fff;
}
@media (prefers-color-scheme: dark) {
::selection {
background-color: #fff;
color: #000;
}
}
/* Article tables */
article table {
width: 100%;
font-size: 0.875rem;
margin-bottom: 1rem;
border-collapse: collapse;
}
article th {
border-bottom: 1px solid #e5e5e5;
padding: 0.5rem 0.75rem;
text-align: left;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #737373;
}
article td {
border-bottom: 1px solid #f5f5f5;
padding: 0.5rem 0.75rem;
color: #525252;
}
:is(.dark) article th {
border-bottom-color: #262626;
color: #a3a3a3;
}
:is(.dark) article td {
border-bottom-color: rgba(38, 38, 38, 0.5);
color: #a3a3a3;
}
button {
cursor: pointer;
}
/* Code blocks */
pre {
background: var(--card) !important;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.875rem;
overflow-x: auto;
font-family: var(--font-geist-mono), monospace;
font-size: 0.8125rem;
line-height: 1.7;
}
pre:not(.shiki) {
background: var(--muted);
}
.code-block pre {
margin: 0;
}
@@ -127,17 +125,43 @@ pre {
}
}
code {
font-family: var(--font-geist-mono), monospace;
}
:not(pre) > code {
background: var(--card);
background: var(--muted);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.875em;
}
/* Diff line highlighting */
.diff-add {
color: #00952d;
}
.diff-remove {
color: #f32e40;
}
.dark .diff-add {
color: #00ca50;
}
.dark .diff-remove {
color: #f32e40;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
color: var(--shiki-light) !important;
background-color: var(--shiki-light-bg) !important;
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
/* Prose */
.prose {
max-width: 100%;
@@ -145,9 +169,9 @@ code {
.prose h1 {
font-size: 1.5rem;
font-weight: 500;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
margin-bottom: 1.5rem;
color: var(--foreground);
}
@@ -158,59 +182,102 @@ code {
}
.prose h2 {
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0;
text-transform: uppercase;
color: var(--muted-foreground);
font-size: 1.125rem;
font-weight: 600;
margin-top: 3rem;
margin-bottom: 1rem;
color: var(--foreground);
}
.prose h2:first-child {
margin-top: 0;
}
.prose h3 {
font-size: 0.875rem;
font-weight: 500;
font-size: 1rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: var(--foreground);
opacity: 0.85;
}
.prose p {
margin-bottom: 1.25rem;
line-height: 1.7;
color: var(--muted-foreground);
margin-bottom: 1rem;
line-height: 1.65;
color: #525252;
font-size: 0.875rem;
}
:is(.dark) .prose p {
color: #a3a3a3;
}
.prose ul, .prose ol {
margin-bottom: 1.25rem;
margin-bottom: 1rem;
padding-left: 1.25rem;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin-bottom: 0.5rem;
color: var(--muted-foreground);
margin-bottom: 0.25rem;
color: #525252;
font-size: 0.875rem;
line-height: 1.6;
}
:is(.dark) .prose li {
color: #a3a3a3;
}
.prose li strong {
color: var(--foreground);
opacity: 0.85;
font-weight: 500;
}
.prose a {
color: var(--foreground);
text-decoration: underline;
text-decoration-color: #d4d4d4;
text-underline-offset: 2px;
}
.prose a:hover {
text-decoration-color: var(--foreground);
}
:is(.dark) .prose a {
text-decoration-color: #525252;
}
:is(.dark) .prose a:hover {
text-decoration-color: var(--foreground);
}
.prose strong {
font-weight: 500;
color: var(--foreground);
}
.prose blockquote {
margin-bottom: 1rem;
border-left: 2px solid #e5e5e5;
padding-left: 1rem;
font-size: 0.875rem;
color: #737373;
}
:is(.dark) .prose blockquote {
border-left-color: #525252;
color: #a3a3a3;
}
.prose table {
width: 100%;
border-collapse: collapse;
@@ -284,7 +351,3 @@ code {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
button {
cursor: pointer;
}

Some files were not shown because too many files have changed in this diff Show More