Compare commits

..
Author SHA1 Message Date
leeguoooooandClaude Opus 4.6 caad12de81 fix(stealth): achieve 0% headless via CDP-native automation override
Key insight: ANY JS-level modification to navigator.webdriver is detectable
by creepjs's lieProps system. The only undetectable approach is
Emulation.setAutomationOverride at the CDP protocol level, which tells
Chrome to natively return false for navigator.webdriver.

In CdpAttach mode, we now inject ZERO JavaScript patches — the browser's
real fingerprint is already perfect. Only the CDP protocol command is needed.

CreepJS results now match manual Chrome exactly:
- 0% headless (was 33%)
- 0% stealth (unchanged)
- 25% like headless (Chrome baseline, same as manual)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 02:35:25 +09:00
leeguoooooandClaude Opus 4.6 51c7ee0e2c fix(stealth): use getter-based webdriver override to match native Chrome shape
CreepJS detects three things for webDriverIsOn:
1. Property deletion (navigator.webdriver === undefined)
2. Value check (!!navigator.webdriver)
3. Lie detection (descriptor tampering via lieProps)

Changed from delete/defineProperty-value approach to replacing the CDP
getter with a getter returning false, matching the native descriptor shape.

Note: 33% headless in CreepJS is a CDP-inherent signal (lieProps detects
the getter replacement). This cannot be eliminated at the JS layer since
CDP sets the webdriver getter before init scripts run. Real-world impact
is minimal — Cloudflare Turnstile passes successfully.

Also confirmed: Chrome's remote_debugging preference in Local State
persists across restarts, so users only need to enable CDP once via
chrome://inspect/#remote-debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 02:25:22 +09:00
leeguoooooandClaude Opus 4.6 193467fdb7 fix(stealth): split minimal/full mode to eliminate detection lies on real Chrome
- CdpAttach mode: only removes navigator.webdriver (user's real Chrome
  already has genuine fingerprint, heavy patches create detectable lies)
- FullLaunch mode: applies all 32 patches (new Chrome needs full coverage)
- Improved webdriver removal: uses Object.defineProperty to override CDP
  getter on Navigator.prototype, not just delete
- CreepJS results: 0% stealth (was 20%), hasIframeProxy: gone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 02:03:12 +09:00
leeguoooooandClaude Opus 4.6 a821006cc0 feat(connect): make auto-connect to user's Chrome the default behavior
- Auto-connect is now ON by default (was opt-in via --auto-connect)
- Added --launch/--new flags to explicitly start a fresh browser
- CI environments (CI env var) automatically use --launch mode
- Friendly error message with platform-specific Chrome relaunch guide
- Mentions Chrome 144+ runtime CDP toggle (chrome://inspect)
- --cdp and --provider flags implicitly disable auto-connect
- AGENT_BROWSER_NO_AUTO_CONNECT=1 to disable, AGENT_BROWSER_FORCE_LAUNCH=1 to force

Track 3 of native-stealth migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 01:28:09 +09:00
leeguoooooandClaude Opus 4.6 4a55e3407b feat(stealth): inject anti-detection patches in native Rust architecture
- Created cli/src/native/stealth.rs with stealth JS injection via CDP
- Extracted 32 patch IIFEs from TS stealth.ts into stealth_scripts.js
- Injected via Page.addScriptToEvaluateOnNewDocument on every launch/connect
- Added stealth Chrome args (disable AutomationControlled, use ANGLE GL)
- Auto-detects and cleans HeadlessChrome from User-Agent string
- Overrides navigator.userAgentData high-entropy hints
- Stealth enabled by default, disable with AGENT_BROWSER_STEALTH=0

Track 2 of native-stealth migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 00:24:50 +09:00
leeguoooooandClaude Opus 4.6 4d57c3e69d feat(rebase): fork base on upstream v0.24.0 native architecture
- Rebased onto upstream/main (v0.24.0, full Rust native)
- Renamed package to agent-browser-stealth, version 0.24.0-fork.1
- Preserved fork-specific: abs alias, extensions/tab-group-cdp, .husky hooks
- Removed upstream-only: docs/, packages/dashboard, examples/, benchmarks/
- Simplified pnpm workspace to root-only
- Added [[bin]] section to keep binary name as "agent-browser"

Track 1 of native-stealth migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 00:14:11 +09:00
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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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
Chris Tatectategithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Stefan SmiljkoviczhanbaxuyongliangxuyongliangThomas Kosiewski
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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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] <41898282+github-actions[bot]@users.noreply.github.com> 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
volarecopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>wanghanzhen
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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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
193 changed files with 36326 additions and 45089 deletions
-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"
+37 -114
View File
@@ -18,43 +18,6 @@ jobs:
- name: Check version sync
run: node scripts/check-version-sync.js
typescript:
name: TypeScript (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Typecheck
run: pnpm typecheck
- name: Format check
run: pnpm format:check
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run tests
run: pnpm test
rust:
name: Rust
runs-on: ubuntu-latest
@@ -64,12 +27,20 @@ jobs:
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: cli
- name: Format check
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
- name: Clippy check
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
- name: Run Rust tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml
@@ -84,7 +55,7 @@ jobs:
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-latest-8-cores
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
@@ -104,27 +75,40 @@ jobs:
- name: Run Rust tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
native-e2e:
name: Native E2E Tests
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: rust
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: cli
- name: Install Chrome
run: |
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
- name: Run e2e tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
windows-integration:
name: Windows Integration Test
if: github.event_name != 'pull_request'
runs-on: windows-latest-8-cores
runs-on: windows-latest
needs: rust-cross
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: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
@@ -138,12 +122,6 @@ jobs:
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory
run: |
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
@@ -161,18 +139,6 @@ jobs:
shell: pwsh
timeout-minutes: 10
- name: Verify Chromium was installed
run: |
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
if (Test-Path $playwrightPath) {
Write-Host "Playwright browsers installed at: $playwrightPath"
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
} else {
Write-Error "Playwright browsers not found!"
exit 1
}
shell: pwsh
- name: Test daemon lifecycle (open, snapshot, close)
run: |
$env:PATH = "$pwd\bin;$env:PATH"
@@ -190,37 +156,6 @@ jobs:
shell: pwsh
timeout-minutes: 5
serverless-chromium:
name: Serverless Chromium (@sparticuz/chromium)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Install @sparticuz/chromium
run: pnpm add -D @sparticuz/chromium
- name: Build TypeScript
run: pnpm build
- name: Run serverless integration test
run: pnpm exec vitest run test/serverless.test.ts
global-install:
name: Global Install (${{ matrix.os }})
if: github.event_name != 'pull_request'
@@ -235,7 +170,7 @@ jobs:
- os: macos-latest
target: aarch64-apple-darwin
binary: agent-browser-darwin-arm64
- os: windows-latest-8-cores
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: agent-browser-win32-x64.exe
@@ -243,16 +178,10 @@ 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
@@ -267,12 +196,6 @@ jobs:
- name: Build Rust CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
- name: Install npm dependencies
run: pnpm install
- name: Build TypeScript
run: pnpm build
- name: Copy CLI binary to bin directory (Unix)
if: runner.os != 'Windows'
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
@@ -299,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)
@@ -314,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
+95 -79
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
@@ -128,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.publish_metadata.outputs.published }}
publishedPackages: ${{ steps.publish_metadata.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
@@ -152,8 +183,9 @@ jobs:
with:
node-version: '22'
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download all binary artifacts
@@ -175,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
@@ -199,69 +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
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 }}
- name: Check if publish is needed
id: publish_check
if: steps.changesets.outputs.hasChangesets == 'false'
run: |
LOCAL_VERSION=$(node -p "require('./package.json').version")
REMOTE_VERSION=$(npm view agent-browser-stealth version 2>/dev/null || echo "")
echo "local_version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
echo "remote_version=$REMOTE_VERSION" >> "$GITHUB_OUTPUT"
if [ "$LOCAL_VERSION" != "$REMOTE_VERSION" ]; then
echo "needs_publish=true" >> "$GITHUB_OUTPUT"
else
echo "needs_publish=false" >> "$GITHUB_OUTPUT"
fi
echo "Local: $LOCAL_VERSION"
echo "Remote: ${REMOTE_VERSION:-<none>}"
- name: Publish to npm (trusted publishing)
id: publish_npm
if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true'
env:
NODE_AUTH_TOKEN: ""
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc
NPM_CONFIG_PROVENANCE: "true"
run: |
npm install -g npm@^11
npm --version
printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG"
pnpm ci:publish
- name: Set release outputs
id: publish_metadata
run: |
if [ "${{ steps.publish_npm.outcome }}" = "success" ]; then
echo "published=true" >> "$GITHUB_OUTPUT"
echo "publishedPackages=[{\"name\":\"agent-browser-stealth\",\"version\":\"${{ steps.publish_check.outputs.local_version }}\"}]" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
echo "publishedPackages=[]" >> "$GITHUB_OUTPUT"
fi
# 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
@@ -279,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 }}
+8
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
@@ -45,6 +46,9 @@ yarn.lock
.env
.env.local
# Windows debug instance config
scripts/windows-debug/.instance
# opensrc - source code for packages
opensrc/
@@ -56,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
+126 -18
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,35 +17,80 @@ 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.
## Dual Architecture (Node.js + Native)
## Dashboard (packages/dashboard)
The codebase has two daemon implementations:
- 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.
- **Node.js/Playwright** (default) -- `src/daemon.ts`, `src/actions.ts`, `src/browser.ts`, and the rest of `src/`
- **Rust/Native** (experimental, `--native` or `AGENT_BROWSER_NATIVE=1`) -- `cli/src/native/daemon.rs`, `cli/src/native/actions.rs`, `cli/src/native/browser.rs`, and the rest of `cli/src/native/`
## Releasing
When modifying browser automation logic (commands, actions, protocol handling), changes **must** be made in **both** paths:
Releases are manual, single-PR affairs. There is no changesets automation. The maintainer controls the changelog voice and format.
| Node.js Path | Native Path |
|---|---|
| `src/actions.ts` | `cli/src/native/actions.rs` |
| `src/browser.ts` | `cli/src/native/browser.rs` |
| `src/daemon.ts` | `cli/src/native/daemon.rs` |
| `src/protocol.ts` | `cli/src/native/cdp/client.rs` |
| `src/snapshot.ts` | `cli/src/native/snapshot.rs` |
| `src/state-utils.ts` | `cli/src/native/state.rs` |
To prepare a release:
New commands must be implemented in both paths, or explicitly stubbed in the native path with a clear `"Not yet implemented: {action}"` error. The goal is eventual full migration to native, but until then both paths must stay in sync.
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
@@ -77,6 +123,68 @@ 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
-244
View File
@@ -1,244 +0,0 @@
# agent-browser
## 0.15.2-fork.0
### Patch Changes
- Merge upstream `v0.15.2` updates, including fixes for cookies clear/tab close output, daemon EPERM liveness checks, unnamed element reference matching, and docs/skills refresh.
## 0.15.1-fork.11
### Patch Changes
- Auto-attach existing browser more reliably by trying CDP localhost:9333 first, then falling back to auto-discovery before failing.
Align daemon behavior and user-facing docs/skill guidance with the same attachment policy.
## 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
### 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
## 0.14.0
### Minor Changes
- b7665e5: - Added `keyboard` command for raw keyboard input -- type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector.
- Added `--color-scheme` flag and `AGENT_BROWSER_COLOR_SCHEME` env var for persistent dark/light mode preference across browser sessions.
- Fixed IPC EAGAIN errors (os error 35/11) by adding backpressure-aware socket writes, command serialization, and lowering the 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.
## 0.13.0
### Minor Changes
- ebd8717: Added new diff commands for comparing snapshots, screenshots, and URLs between page states. You can now 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.
## 0.12.0
### Minor Changes
- 69ffad0: Add annotated screenshots with the new --annotate flag, which overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. This enables multimodal AI models to reason about visual layout while using the same @eN refs for subsequent interactions. The flag can also be set via the AGENT_BROWSER_ANNOTATE environment variable.
## 0.11.1
### Patch Changes
- c6fc7df: Added documentation for command chaining with && across README, CLI help output, docs, and skill files, explaining how to efficiently chain multiple agent-browser commands in a single shell invocation since the browser persists via a background daemon.
## 0.11.0
### Minor Changes
- 5dc40b4: Added configuration file support with automatic loading from user and project directories, new profiler commands for Chrome DevTools profiling, computed styles getter, browser extension loading, storage state management, and iOS device emulation. Expanded click command with new-tab option, improved find command with additional actions and filtering options, and enhanced CDP connection to accept WebSocket URLs. Documentation has been significantly expanded with new sections for configuration, profiling, and proxy support.
## 0.10.0
### Minor Changes
- 1112a16: Added session persistence with automatic save/restore of cookies and localStorage across browser restarts using --session-name flag, with optional AES-256-GCM encryption for saved state data. New state management commands allow listing, showing, renaming, clearing, and cleaning up old session files. Also added --new-tab option for click commands to open links in new tabs.
## 0.9.4
### Patch Changes
- 323b6cd: Fix all Clippy lint warnings in the Rust CLI: remove redundant import, use `.first()` instead of `.get(0)`, use `.copied()` instead of `.map(|s| *s)`, use `.contains()` instead of `.iter().any()`, use `then_some` instead of lazy `then`, and simplify redundant match guards.
## 0.9.3
### Patch Changes
- d03e238: Added support for custom executable path in CLI browser launch options. Documentation site received UI improvements including a new chat component with sheet-based interface and updated dependencies.
## 0.9.2
### Patch Changes
- 76d23db: Documentation site migrated to MDX for improved content authoring, added AI-powered docs chat feature, and updated README with Homebrew installation instructions for macOS users.
## 0.9.1
### Patch Changes
- ae34945: Added --allow-file-access flag to enable opening and interacting with local file:// URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files. Added -C/--cursor flag for snapshots to include cursor-interactive elements like divs with onclick handlers or cursor:pointer styles, which is useful for modern web apps using custom clickable elements.
## 0.9.0
### Minor Changes
- 9d021bd: Add iOS Simulator and real device support for mobile Safari testing via Appium. New CLI commands include `device list` to show available simulators, `tap` and `swipe` for touch interactions, and the `--device` flag to specify which iOS device to use. Configure with `-p ios` provider flag or `AGENT_BROWSER_PROVIDER=ios` environment variable.
## 0.8.10
### Patch Changes
- 17dba8f: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
- daeede4: Add --stdin flag for the eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts. Also fix binary permission issues on macOS/Linux when postinstall scripts don't run (e.g., with bun).
## 0.8.9
### Patch Changes
- 0dc36f2: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
## 0.8.8
### Patch Changes
- 2771588: Added base64 encoding support for the eval command with -b/--base64 flag to avoid shell escaping issues when executing JavaScript. Updated documentation with AI agent setup instructions and reorganized the docs structure by consolidating agent mode content into the installation page.
## 0.8.7
### Patch Changes
- d24f753: Fixed browser launch options not being passed correctly when using persistent profiles, ensuring args, userAgent, proxy, and ignoreHTTPSErrors settings now work properly. Added pre-flight checks for socket path length limits and directory write permissions to provide clearer error messages when daemon startup fails. Improved error handling to properly exit with failure status when browser launch fails.
## 0.8.6
### Patch Changes
- d75350a: Improved daemon connection reliability by adding automatic retry logic for transient errors like connection resets, broken pipes, and temporary resource unavailability. The CLI now cleans up stale socket and PID files before starting a new daemon, and includes better detection of daemon responsiveness to handle race conditions during shutdown.
## 0.8.5
### Patch Changes
- cb2f8c3: Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases, and made the CLI binary executable. This ensures the Rust CLI version stays in sync with the npm package version.
## 0.8.4
### Patch Changes
- 759302e: Fixed "Daemon not found" error when running through AI agents (e.g., Claude Code) by resolving symlinks in the executable path. Previously, npm global bin symlinks weren't being resolved correctly, causing intermittent daemon discovery failures.
## 0.8.3
### Patch Changes
- 4116a8a: Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows. Added postinstall logic to patch npm's bin entry on global installs, allowing the native binary to be invoked directly with zero overhead. Added CI tests to verify global installation works correctly across all platforms.
## 0.8.2
### Patch Changes
- 7e6336f: Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js, improving startup performance and reliability. Added retry logic to the CI install command to handle transient failures during browser installation.
## 0.8.1
### Patch Changes
- 8eec634: Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install. Updated documentation site with a new mobile navigation system and added v0.8.0 changelog entries. Reformatted CHANGELOG.md for better readability.
## v0.8.0
### New Features
- **Kernel cloud browser provider** - Connect to Kernel (https://kernel.sh) for remote browser infrastructure via `-p kernel` flag or `AGENT_BROWSER_PROVIDER=kernel`. Supports stealth mode, persistent profiles, and automatic profile find-or-create.
- **Ignore HTTPS certificate errors** - New `--ignore-https-errors` flag for working with self-signed certificates and development environments
- **Enhanced cookie management** - Extended `cookies set` command with `--url`, `--domain`, `--path`, `--httpOnly`, `--secure`, `--sameSite`, and `--expires` flags for setting cookies before page load
### Bug Fixes
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links (#275)
- Fixed `check` command hanging indefinitely (#272)
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly (#270)
- Fixed state load and profile persistence not working in v0.7.6 (#268)
- Screenshots now save to temp directory when no path is provided (#247)
### Security
- Daemon and stream server now reject cross-origin connections (#274)
## 0.7.6
### Patch Changes
- a4d0c26: Allow null values for the screenshot selector field. Previously, passing a null selector would fail validation, but now it is properly handled as an optional value.
## 0.7.5
### Patch Changes
- 8c2a6ec: Fix GitHub release workflow to handle existing releases. If a release already exists, binaries are uploaded to it instead of failing.
## 0.7.4
### Patch Changes
- 957b5e5: Fix binary permissions on install. npm doesn't preserve execute bits, so postinstall now ensures the native binary is executable.
## 0.7.3
### Patch Changes
- 161d8f5: Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now correctly included when publishing.
## 0.7.2
### Patch Changes
- 6afede2: Fix native binary distribution in npm package
Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
## 0.7.1
### Patch Changes
- Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
## 0.7.0
### 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://..."`)
- **Download commands** - New `download` command and `wait --download` for file downloads with ref support
- **Browser launch configuration** - New `--args`, `--user-agent`, and `--proxy-bypass` flags for fine-grained browser control
- **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)
- Windows binary path fix (.exe extension)
- 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
+1303 -221
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
/Users/leo/github.com/agent-browser/cli/target/release/agent-browser: /Users/leo/github.com/agent-browser/cli/build.rs /Users/leo/github.com/agent-browser/cli/cdp-protocol/browser_protocol.json /Users/leo/github.com/agent-browser/cli/cdp-protocol/js_protocol.json /Users/leo/github.com/agent-browser/cli/src/color.rs /Users/leo/github.com/agent-browser/cli/src/commands.rs /Users/leo/github.com/agent-browser/cli/src/connection.rs /Users/leo/github.com/agent-browser/cli/src/flags.rs /Users/leo/github.com/agent-browser/cli/src/install.rs /Users/leo/github.com/agent-browser/cli/src/main.rs /Users/leo/github.com/agent-browser/cli/src/output.rs /Users/leo/github.com/agent-browser/cli/src/validation.rs
+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';
+263 -36
View File
@@ -45,26 +45,34 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.16.1-fork.0"
version = "0.24.0-fork.1"
dependencies = [
"aes-gcm",
"async-trait",
"base64",
"chrono",
"dirs",
"futures-util",
"getrandom 0.2.17",
"hex",
"hmac",
"image",
"libc",
"regex-lite",
"reqwest",
"serde",
"serde_json",
"sha2",
"similar",
"socket2",
"time",
"tokio",
"tokio-tungstenite",
"url",
"urlencoding",
"uuid",
"windows-sys 0.52.0",
"zip",
]
[[package]]
@@ -85,6 +93,15 @@ dependencies = [
"equator",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -285,6 +302,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -301,6 +331,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
@@ -385,6 +421,15 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -393,6 +438,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -527,6 +573,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -628,20 +675,20 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"r-efi",
"wasip2",
"wasip3",
]
@@ -698,6 +745,21 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -772,7 +834,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.6",
"webpki-roots 1.0.5",
]
[[package]]
@@ -798,6 +860,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -1043,9 +1129,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libfuzzer-sys"
@@ -1059,10 +1145,11 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"libc",
]
@@ -1105,9 +1192,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "miniz_oxide"
@@ -1171,6 +1258,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -1293,6 +1386,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1314,9 +1413,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
dependencies = [
"unicode-ident",
]
@@ -1418,9 +1517,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
dependencies = [
"proc-macro2",
]
@@ -1431,12 +1530,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
@@ -1577,6 +1670,12 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -1612,7 +1711,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.6",
"webpki-roots 1.0.5",
]
[[package]]
@@ -1844,9 +1943,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
@@ -1927,6 +2026,37 @@ dependencies = [
"zune-jpeg 0.4.21",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -1954,9 +2084,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes",
"libc",
@@ -2095,6 +2225,12 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -2103,9 +2239,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-xid"
@@ -2141,6 +2277,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -2159,7 +2301,7 @@ version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"getrandom 0.4.2",
"getrandom 0.4.1",
"js-sys",
"wasm-bindgen",
]
@@ -2333,14 +2475,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
"webpki-roots 1.0.5",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
@@ -2351,12 +2493,65 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -2783,10 +2978,42 @@ dependencies = [
]
[[package]]
name = "zmij"
version = "1.0.21"
name = "zip"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
+16 -7
View File
@@ -1,25 +1,27 @@
[package]
name = "agent-browser-stealth"
version = "0.16.1-fork.1"
version = "0.24.0-fork.1"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
repository = "https://github.com/leeguooooo/agent-browser-stealth"
homepage = "https://github.com/leeguooooo/agent-browser-stealth"
readme = "../README.md"
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "agent-browser"
path = "src/main.rs"
[[bin]]
name = "agent-browser-stealth"
path = "src/main_stealth.rs"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex-lite = "0.1"
dirs = "5.0"
base64 = "0.22"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal"] }
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"
@@ -29,7 +31,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
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"
+3 -3
View File
@@ -175,7 +175,7 @@ fn to_snake_case(s: &str) -> String {
// 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).map_or(false, |n| n.is_lowercase());
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
if !prev_upper || next_lower {
result.push('_');
}
@@ -202,7 +202,7 @@ fn resolve_ref(
// Check if this type actually exists in the referenced domain
if domain_types
.get(ref_domain)
.map_or(false, |t| t.contains(ref_type))
.is_some_and(|t| t.contains(ref_type))
{
format!(
"super::cdp_{}::{}",
@@ -339,7 +339,7 @@ fn generate_domain(
if variant == "Self" {
variant = "SelfValue".to_string();
}
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
variant = format!("V{}", variant);
}
if seen_variants.insert(variant.clone()) {
+762 -290
View File
File diff suppressed because it is too large Load Diff
+241 -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)]
@@ -118,14 +120,10 @@ fn get_pid_path(session: &str) -> PathBuf {
/// Clean up stale socket and PID files for a session
fn cleanup_stale_files(session: &str) {
// Never delete files for a live daemon. A missing PID file can happen in
// race scenarios, but the socket is authoritative for liveness.
if daemon_ready(session) {
return;
}
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)]
{
@@ -146,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);
@@ -156,7 +154,19 @@ fn get_port_for_session(session: &str) -> u16 {
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
}
fn daemon_ready(session: &str) -> bool {
/// 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)]
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))
}
pub fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
@@ -164,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),
@@ -179,29 +189,131 @@ 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,
download_path: Option<&str>,
tab_group: Option<&str>,
tab_group_plugin_id: Option<&str>,
) -> Result<DaemonResult, String> {
// Socket readiness is the source of truth for a usable daemon.
// PID files can be missing/stale under concurrent start/stop races.
/// 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 force_launch: 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 opts.force_launch {
cmd.env("AGENT_BROWSER_FORCE_LAUNCH", "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
@@ -256,207 +368,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");
}
if let Some(dp) = download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(tg) = tab_group {
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
}
if let Some(plugin_id) = tab_group_plugin_id {
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
}
// 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");
}
if let Some(dp) = download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(tg) = tab_group {
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
}
if let Some(plugin_id) = tab_group_plugin_id {
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
}
// 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 {
@@ -465,13 +424,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> {
@@ -484,7 +477,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))
@@ -542,6 +535,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> {
@@ -569,45 +564,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"));
}
@@ -616,8 +580,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()
@@ -628,8 +592,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(),
@@ -641,8 +605,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()
@@ -653,8 +617,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"));
@@ -748,6 +712,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
@@ -756,4 +734,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);
}
}
+384 -296
View File
File diff suppressed because it is too large Load Diff
+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(())
}
+896 -323
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
include!("main.rs");
+4015 -810
View File
File diff suppressed because it is too large Load Diff
+312 -71
View File
@@ -1,11 +1,13 @@
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 sha2::{Digest, Sha256};
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,
@@ -17,6 +19,10 @@ pub struct AuthProfile {
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
@@ -48,79 +54,203 @@ fn get_profile_path(name: &str) -> PathBuf {
get_auth_dir().join(format!("{}.json", name))
}
fn derive_encryption_key() -> Vec<u8> {
let hostname = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| {
#[cfg(unix)]
{
let mut buf = [0u8; 256];
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
if len == 0 {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).to_string()
} else {
"unknown-host".to_string()
}
}
#[cfg(not(unix))]
{
"unknown-host".to_string()
}
});
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown-user".to_string());
let mut hasher = Sha256::new();
hasher.update(format!("agent-browser:{}:{}", hostname, username).as_bytes());
hasher.finalize().to_vec()
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 encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> {
let key = derive_encryption_key();
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 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), plaintext.as_bytes())
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 mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce);
result.extend_from_slice(&ciphertext);
Ok(result)
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> {
if data.len() < 13 {
return Err("Encrypted data too short".to_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));
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let key = derive_encryption_key();
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
.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))?;
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();
let _ = fs::create_dir_all(&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 = encrypt_profile(profile)?;
let encrypted_json = encrypt_profile(profile)?;
let path = get_profile_path(&profile.name);
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write profile: {}", e))
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> {
@@ -147,6 +277,8 @@ pub fn credentials_set(
username_selector: None,
password_selector: None,
submit_selector: None,
created_at: None,
last_login_at: None,
};
save_profile(&profile)?;
Ok(json!({ "saved": name }))
@@ -170,6 +302,8 @@ pub fn auth_save(
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 }))
@@ -252,10 +386,27 @@ pub fn auth_show(name: &str) -> Result<Value, String> {
}))
}
#[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());
@@ -277,6 +428,8 @@ mod tests {
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();
@@ -290,26 +443,114 @@ mod tests {
#[test]
fn test_encrypt_decrypt_roundtrip() {
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,
};
let encrypted = encrypt_profile(&profile).unwrap();
let decrypted = decrypt_profile(&encrypted).unwrap();
assert_eq!(decrypted.name, "roundtrip");
assert_eq!(decrypted.password, "s3cret!");
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_derive_encryption_key_is_stable() {
let k1 = derive_encryption_key();
let k2 = derive_encryption_key();
assert_eq!(k1, k2);
assert_eq!(k1.len(), 32);
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());
});
}
}
+844 -115
View File
File diff suppressed because it is too large Load Diff
+642 -126
View File
@@ -1,13 +1,14 @@
use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use super::types::BrowserVersionInfo;
use super::discovery::discover_cdp_url;
pub struct ChromeProcess {
child: Child,
pub ws_url: String,
temp_user_data_dir: Option<PathBuf>,
}
impl ChromeProcess {
@@ -15,11 +16,60 @@ impl ChromeProcess {
let _ = self.child.kill();
let _ = self.child.wait();
}
/// Returns the OS process ID of the Chrome child process.
pub fn id(&self) -> u32 {
self.child.id()
}
/// Non-blocking check whether Chrome has exited.
/// Returns `true` if the process has exited (and reaps it), `false` if still running.
pub fn has_exited(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(Some(_)) | Err(_))
}
/// Wait for Chrome to exit on its own (after Browser.close CDP command),
/// falling back to kill() if it doesn't exit within the timeout.
/// This allows Chrome to flush cookies and other state to the user-data-dir.
pub fn wait_or_kill(&mut self, timeout: Duration) {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(50);
while start.elapsed() < timeout {
match self.child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => std::thread::sleep(poll_interval),
Err(_) => break,
}
}
self.kill();
}
}
impl Drop for ChromeProcess {
fn drop(&mut self) {
self.kill();
if let Some(ref dir) = self.temp_user_data_dir {
for attempt in 0..3 {
match std::fs::remove_dir_all(dir) {
Ok(()) => break,
Err(_) if attempt < 2 => {
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
// Use write! instead of eprintln! to avoid panicking
// if the daemon's stderr pipe is broken (parent dropped it).
let _ = writeln!(
std::io::stderr(),
"Warning: failed to clean up temp profile {}: {}",
dir.display(),
e
);
}
}
}
}
}
}
@@ -28,6 +78,8 @@ pub struct LaunchOptions {
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub proxy_username: Option<String>,
pub proxy_password: Option<String>,
pub profile: Option<String>,
pub args: Vec<String>,
pub allow_file_access: bool,
@@ -46,6 +98,8 @@ impl Default for LaunchOptions {
executable_path: None,
proxy: None,
proxy_bypass: None,
proxy_username: None,
proxy_password: None,
profile: None,
args: Vec::new(),
allow_file_access: false,
@@ -59,18 +113,21 @@ impl Default for LaunchOptions {
}
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => {
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
}
};
struct ChromeArgs {
args: Vec<String>,
user_data_dir: PathBuf,
temp_user_data_dir: Option<PathBuf>,
}
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let mut args = vec![
"--remote-debugging-port=0".to_string(),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
// Stealth: reduce automation fingerprint surface
"--disable-blink-features=AutomationControlled".to_string(),
"--use-gl=angle".to_string(),
"--use-angle=default".to_string(),
"--disable-background-networking".to_string(),
"--disable-backgrounding-occluded-windows".to_string(),
"--disable-component-update".to_string(),
@@ -79,14 +136,27 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
"--disable-popup-blocking".to_string(),
"--disable-prompt-on-repost".to_string(),
"--disable-sync".to_string(),
"--disable-features=Translate".to_string(),
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
"--metrics-recording-only".to_string(),
"--password-store=basic".to_string(),
"--use-mock-keychain".to_string(),
];
if options.headless {
let has_extensions = options
.extensions
.as_ref()
.is_some_and(|exts| !exts.is_empty());
// Extensions require headed mode in native Chrome (content scripts are not
// injected in headless mode). Skip --headless when extensions are loaded.
if options.headless && !has_extensions {
args.push("--headless=new".to_string());
// Enable SwiftShader software rendering in headless mode. This
// prevents silent crashes in environments where GPU drivers are
// missing or restricted (VMs, containers, some cloud machines)
// while preserving WebGL support. Playwright uses the same flag.
args.push("--enable-unsafe-swiftshader".to_string());
}
if let Some(ref proxy) = options.proxy {
@@ -97,10 +167,19 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
args.push(format!("--proxy-bypass-list={}", bypass));
}
if let Some(ref profile) = options.profile {
let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
let expanded = expand_tilde(profile);
let dir = PathBuf::from(&expanded);
args.push(format!("--user-data-dir={}", expanded));
}
(dir, None)
} else {
let dir =
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
args.push(format!("--user-data-dir={}", dir.display()));
(dir.clone(), Some(dir))
};
if options.allow_file_access {
args.push("--allow-file-access-from-files".to_string());
@@ -115,13 +194,12 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
}
}
// Check if user args set window size (skip viewport override)
let has_window_size = options
.args
.iter()
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
if !has_window_size && options.headless {
if !has_window_size && options.headless && !has_extensions {
args.push("--window-size=1280,720".to_string());
}
@@ -131,27 +209,161 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
args.push("--no-sandbox".to_string());
}
let mut child = Command::new(&chrome_path)
if should_disable_dev_shm(&args) {
args.push("--disable-dev-shm-usage".to_string());
}
Ok(ChromeArgs {
args,
user_data_dir,
temp_user_data_dir,
})
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => find_chrome().ok_or_else(|| {
let cache_dir = crate::install::get_browsers_dir();
format!(
"Chrome not found. Checked:\n \
- agent-browser cache: {}\n \
- System Chrome installations\n \
- Puppeteer browser cache\n \
- Playwright browser cache\n\
Run `agent-browser install` to download Chrome, or use --executable-path.",
cache_dir.display()
)
})?,
};
let max_attempts = 3;
let mut last_err = String::new();
for attempt in 1..=max_attempts {
match try_launch_chrome(&chrome_path, options) {
Ok(process) => return Ok(process),
Err(e) => {
last_err = e;
if attempt < max_attempts {
// Use write! instead of eprintln! to avoid panicking
// if the daemon's stderr pipe is broken (parent dropped it).
let _ = writeln!(
std::io::stderr(),
"[chrome] Launch attempt {}/{} failed, retrying in 500ms...",
attempt,
max_attempts
);
std::thread::sleep(Duration::from_millis(500));
}
}
}
}
Err(last_err)
}
fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result<ChromeProcess, String> {
let ChromeArgs {
args,
user_data_dir,
temp_user_data_dir,
} = build_chrome_args(options)?;
// Mitigate stale DevToolsActivePort risk (e.g., previous crash left it behind).
// Puppeteer does similar cleanup before spawning.
let _ = std::fs::remove_file(user_data_dir.join("DevToolsActivePort"));
let cleanup_temp_dir = |dir: &Option<PathBuf>| {
if let Some(ref d) = dir {
let _ = std::fs::remove_dir_all(d);
}
};
let mut child = Command::new(chrome_path)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
.map_err(|e| {
cleanup_temp_dir(&temp_user_data_dir);
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
})?;
let stderr = child
.stderr
.take()
.ok_or("Failed to capture Chrome stderr")?;
let reader = BufReader::new(stderr);
// Shared overall deadline so we don't double-wait (poll + stderr fallback).
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let ws_url = wait_for_ws_url(reader)?;
// Primary path: use DevToolsActivePort written into user-data-dir.
// This is more reliable on Windows than scraping stderr for "DevTools listening on ...",
// which can be missing/empty depending on how Chrome is launched.
let ws_url = match wait_for_devtools_active_port(&mut child, &user_data_dir, deadline) {
Ok(url) => url,
Err(primary_err) => {
// Fallback: scrape stderr (legacy behavior) for better diagnostics.
let stderr = child.stderr.take().ok_or_else(|| {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
"Failed to capture Chrome stderr".to_string()
})?;
let reader = BufReader::new(stderr);
match wait_for_ws_url_until(reader, deadline) {
Ok(url) => url,
Err(fallback_err) => {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
return Err(format!(
"{}\n(also tried parsing stderr) {}",
primary_err, fallback_err
));
}
}
}
};
Ok(ChromeProcess { child, ws_url })
Ok(ChromeProcess {
child,
ws_url,
temp_user_data_dir,
})
}
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
fn wait_for_devtools_active_port(
child: &mut Child,
user_data_dir: &Path,
deadline: std::time::Instant,
) -> Result<String, String> {
let poll_interval = Duration::from_millis(50);
while std::time::Instant::now() <= deadline {
if let Ok(Some(status)) = child.try_wait() {
// Chrome exited before writing DevToolsActivePort -- report the
// exit code so the caller can surface it alongside stderr output.
let code = status
.code()
.map(|c| format!("{}", c))
.unwrap_or_else(|| "unknown".to_string());
return Err(format!(
"Chrome exited early (exit code: {}) without writing DevToolsActivePort",
code
));
}
if let Some((port, ws_path)) = read_devtools_active_port(user_data_dir) {
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
return Ok(ws_url);
}
std::thread::sleep(poll_interval);
}
Err("Timeout waiting for DevToolsActivePort".to_string())
}
fn wait_for_ws_url_until(
reader: BufReader<std::process::ChildStderr>,
deadline: std::time::Instant,
) -> Result<String, String> {
let prefix = "DevTools listening on ";
let mut stderr_lines: Vec<String> = Vec::new();
@@ -193,7 +405,10 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
if relevant.is_empty() {
if stderr_lines.is_empty() {
return format!("{} (no stderr output from Chrome)", message);
return format!(
"{} (no stderr output from Chrome)\nHint: try passing --args \"--no-sandbox\" if Chrome crashes silently in your environment",
message
);
}
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
return format!(
@@ -231,12 +446,31 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
}
pub fn find_chrome() -> Option<PathBuf> {
// 1. Check Chrome downloaded by `agent-browser install`
if let Some(p) = crate::install::find_installed_chrome() {
return Some(p);
}
// If the cache directory exists but no Chrome was found, warn -- this
// likely means the cache is corrupted or the directory layout is unexpected.
let cache_dir = crate::install::get_browsers_dir();
if cache_dir.exists() {
let _ = writeln!(
std::io::stderr(),
"Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
Falling back to system Chrome. Run `agent-browser install` to re-download.",
cache_dir.display()
);
}
// 2. Check system-installed Chrome
#[cfg(target_os = "macos")]
{
let candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
];
for c in &candidates {
let p = PathBuf::from(c);
@@ -244,10 +478,6 @@ pub fn find_chrome() -> Option<PathBuf> {
return Some(p);
}
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
}
#[cfg(target_os = "linux")]
@@ -257,6 +487,8 @@ pub fn find_chrome() -> Option<PathBuf> {
"google-chrome-stable",
"chromium-browser",
"chromium",
"brave-browser",
"brave-browser-stable",
];
for name in &candidates {
if let Ok(output) = Command::new("which").arg(name).output() {
@@ -268,10 +500,6 @@ pub fn find_chrome() -> Option<PathBuf> {
}
}
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
}
#[cfg(target_os = "windows")]
@@ -281,9 +509,14 @@ pub fn find_chrome() -> Option<PathBuf> {
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
];
if let Ok(local) = std::env::var("LOCALAPPDATA") {
let p = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
if p.exists() {
return Some(p);
let chrome = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
if chrome.exists() {
return Some(chrome);
}
let brave =
PathBuf::from(&local).join(r"BraveSoftware\Brave-Browser\Application\brave.exe");
if brave.exists() {
return Some(brave);
}
}
for c in &candidates {
@@ -294,78 +527,17 @@ pub fn find_chrome() -> Option<PathBuf> {
}
}
// 3. Fallback: check Puppeteer / Playwright browser caches
if let Some(p) = find_puppeteer_chrome() {
return Some(p);
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
None
}
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
let url = format!("http://127.0.0.1:{}/json/version", port);
let body = tokio::time::timeout(Duration::from_secs(2), async {
reqwest_get_string(&url).await
})
.await
.map_err(|_| format!("Timeout connecting to CDP on port {}", port))?
.map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?;
let info: BrowserVersionInfo = serde_json::from_str(&body)
.map_err(|e| format!("Invalid /json/version response: {}", e))?;
info.web_socket_debugger_url
.ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port))
}
async fn reqwest_get_string(url: &str) -> Result<String, String> {
let client = tokio::net::TcpStream::connect(
url.strip_prefix("http://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or("127.0.0.1:9222"),
)
.await
.map_err(|e| e.to_string())?;
let path = url
.find('/')
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
.unwrap_or("/json/version");
let host = url
.strip_prefix("http://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or("127.0.0.1");
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
path, host
);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut client = client;
client
.write_all(request.as_bytes())
.await
.map_err(|e| e.to_string())?;
let mut response = Vec::new();
client
.read_to_end(&mut response)
.await
.map_err(|e| e.to_string())?;
let response_str = String::from_utf8_lossy(&response);
let body = response_str
.split("\r\n\r\n")
.nth(1)
.unwrap_or("")
.to_string();
Ok(body)
}
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
let path = user_data_dir.join("DevToolsActivePort");
let content = std::fs::read_to_string(&path).ok()?;
@@ -385,18 +557,26 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
for dir in &user_data_dirs {
if let Some((port, ws_path)) = read_devtools_active_port(dir) {
// Try HTTP endpoint first (pre-M144)
if let Ok(ws_url) = discover_cdp_url(port).await {
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
return Ok(ws_url);
}
// M144+: direct WebSocket
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
return Ok(ws_url);
// M144+: direct WebSocket — verify the port is actually listening
// before returning, otherwise a stale DevToolsActivePort file
// (left behind after Chrome exits/crashes) produces a confusing
// "connection refused" error instead of falling through.
if is_port_reachable(port) {
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
return Ok(ws_url);
}
// Port is dead — remove the stale file so future runs skip it.
let stale = dir.join("DevToolsActivePort");
let _ = std::fs::remove_file(&stale);
}
}
// Fallback: probe common ports
for port in [9222u16, 9229] {
if let Ok(ws_url) = discover_cdp_url(port).await {
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
return Ok(ws_url);
}
}
@@ -404,6 +584,12 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
}
fn is_port_reachable(port: u16) -> bool {
use std::net::TcpStream;
let addr = format!("127.0.0.1:{}", port);
TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(500)).is_ok()
}
fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
@@ -411,7 +597,12 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
{
if let Some(home) = dirs::home_dir() {
let base = home.join("Library/Application Support");
for name in ["Google/Chrome", "Google/Chrome Canary", "Chromium"] {
for name in [
"Google/Chrome",
"Google/Chrome Canary",
"Chromium",
"BraveSoftware/Brave-Browser",
] {
dirs.push(base.join(name));
}
}
@@ -421,7 +612,12 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
{
if let Some(home) = dirs::home_dir() {
let config = home.join(".config");
for name in ["google-chrome", "google-chrome-unstable", "chromium"] {
for name in [
"google-chrome",
"google-chrome-unstable",
"chromium",
"BraveSoftware/Brave-Browser",
] {
dirs.push(config.join(name));
}
}
@@ -435,6 +631,7 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
r"Google\Chrome\User Data",
r"Google\Chrome SxS\User Data",
r"Chromium\User Data",
r"BraveSoftware\Brave-Browser\User Data",
] {
dirs.push(base.join(name));
}
@@ -445,12 +642,18 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
}
/// Returns true if Chrome's sandbox should be disabled because the environment
/// doesn't support it (containers, VMs, running as root).
/// doesn't support it (containers, VMs, CI runners, running as root).
fn should_disable_sandbox(existing_args: &[String]) -> bool {
if existing_args.iter().any(|a| a == "--no-sandbox") {
return false; // already set by user
}
// CI environments (GitHub Actions, GitLab CI, etc.) often lack user namespace
// support due to AppArmor or kernel restrictions.
if std::env::var("CI").is_ok() {
return true;
}
#[cfg(unix)]
{
// Root user -- standard container default, Chrome sandbox requires non-root
@@ -470,10 +673,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
// Generic container detection: cgroup contains docker/kubepods/lxc
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
if cgroup.contains("docker")
|| cgroup.contains("kubepods")
|| cgroup.contains("lxc")
{
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
return true;
}
}
@@ -482,8 +682,108 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
false
}
/// Returns true if Chrome should use disk instead of /dev/shm for shared memory.
/// On CI runners and containers, /dev/shm is often too small (64MB default),
/// which causes Chrome to crash mid-session.
fn should_disable_dev_shm(existing_args: &[String]) -> bool {
if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") {
return false;
}
if std::env::var("CI").is_ok() {
return true;
}
#[cfg(unix)]
{
if unsafe { libc::geteuid() } == 0 {
return true;
}
if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
return true;
}
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
return true;
}
}
}
false
}
/// Search Puppeteer's browser cache for a Chrome binary.
/// Puppeteer v19+ stores Chrome in ~/.cache/puppeteer/chrome/<platform>-<version>/
fn find_puppeteer_chrome() -> Option<PathBuf> {
let mut search_dirs = Vec::new();
if let Ok(custom) = std::env::var("PUPPETEER_CACHE_DIR") {
search_dirs.push(PathBuf::from(custom).join("chrome"));
}
if let Some(home) = dirs::home_dir() {
search_dirs.push(home.join(".cache/puppeteer/chrome"));
}
for dir in &search_dirs {
if !dir.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(dir) {
let mut matches: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let candidate = build_puppeteer_binary_path(&e.path());
if candidate.exists() {
Some(candidate)
} else {
None
}
})
.collect();
matches.sort();
matches.reverse();
if let Some(p) = matches.into_iter().next() {
return Some(p);
}
}
}
None
}
#[cfg(target_os = "linux")]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
version_dir.join("chrome-linux64/chrome")
}
#[cfg(target_os = "macos")]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
// Puppeteer uses chrome-mac-arm64 or chrome-mac-x64 depending on arch
let arm = version_dir.join(
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
);
if arm.exists() {
return arm;
}
version_dir.join(
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
)
}
#[cfg(target_os = "windows")]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
version_dir.join(r"chrome-win64\chrome.exe")
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
version_dir.join("chrome")
}
/// Search Playwright's browser cache for a Chromium binary.
/// This is where `agent-browser install` (via `npx playwright install chromium`) puts it.
/// Legacy fallback for users who previously installed Chromium via Playwright.
fn find_playwright_chromium() -> Option<PathBuf> {
let mut search_dirs = Vec::new();
@@ -559,6 +859,29 @@ fn expand_tilde(path: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[cfg(unix)]
fn spawn_noop_child() -> Child {
Command::new("/bin/sh")
.args(["-c", "exit 0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap()
}
#[cfg(windows)]
fn spawn_noop_child() -> Child {
Command::new("cmd.exe")
.args(["/C", "exit 0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap()
}
#[test]
fn test_find_chrome_returns_some_on_host() {
@@ -600,6 +923,8 @@ mod tests {
fn test_chrome_launch_error_no_stderr() {
let msg = chrome_launch_error("Chrome exited", &[]);
assert!(msg.contains("no stderr output"));
assert!(msg.contains("Hint:"));
assert!(msg.contains("--no-sandbox"));
}
#[test]
@@ -616,20 +941,211 @@ mod tests {
#[test]
fn test_chrome_launch_error_generic() {
let lines = vec![
"info line".to_string(),
"another info line".to_string(),
];
let lines = vec!["info line".to_string(), "another info line".to_string()];
let msg = chrome_launch_error("Chrome exited", &lines);
assert!(msg.contains("last 2 lines"));
}
#[test]
fn test_find_playwright_chromium_nonexistent() {
// With no Playwright cache, should return None
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
let guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH", "HOME", "USERPROFILE"]);
guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
let temp_home = std::env::temp_dir().join(format!(
"agent-browser-test-home-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
));
std::fs::create_dir_all(&temp_home).expect("temp home should be created");
let temp_home = temp_home.to_string_lossy().to_string();
guard.set("HOME", &temp_home);
guard.set("USERPROFILE", &temp_home);
let result = find_playwright_chromium();
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
assert!(result.is_none());
}
#[test]
fn test_build_args_headless_includes_headless_flag() {
let opts = LaunchOptions {
headless: true,
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(result.args.iter().any(|a| a == "--headless=new"));
assert!(result
.args
.iter()
.any(|a| a == "--enable-unsafe-swiftshader"));
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
// Temp dir created when no profile
assert!(result.temp_user_data_dir.is_some());
let dir = result.temp_user_data_dir.unwrap();
assert!(dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_args_headed_no_headless_flag() {
let opts = LaunchOptions {
headless: false,
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result.args.iter().any(|a| a.contains("--headless")));
assert!(!result
.args
.iter()
.any(|a| a == "--enable-unsafe-swiftshader"));
assert!(!result.args.iter().any(|a| a.starts_with("--window-size=")));
// Temp dir created when no profile
assert!(result.temp_user_data_dir.is_some());
let dir = result.temp_user_data_dir.unwrap();
assert!(dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_args_temp_user_data_dir_created() {
let opts = LaunchOptions::default();
let result = build_chrome_args(&opts).unwrap();
let dir = result.temp_user_data_dir.as_ref().unwrap();
assert!(dir.exists());
assert!(result
.args
.iter()
.any(|a| a.starts_with("--user-data-dir=")));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn test_build_args_profile_no_temp_dir() {
let opts = LaunchOptions {
profile: Some("/tmp/my-profile".to_string()),
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(result.temp_user_data_dir.is_none());
assert!(result
.args
.iter()
.any(|a| a == "--user-data-dir=/tmp/my-profile"));
}
#[test]
fn test_build_args_custom_window_size_not_overridden() {
let opts = LaunchOptions {
headless: true,
args: vec!["--window-size=1920,1080".to_string()],
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_build_args_start_maximized_suppresses_default_window_size() {
let opts = LaunchOptions {
headless: true,
args: vec!["--start-maximized".to_string()],
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
assert!(result.args.iter().any(|a| a == "--start-maximized"));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_build_args_disables_translate() {
let opts = LaunchOptions::default();
let result = build_chrome_args(&opts).unwrap();
assert!(result
.args
.iter()
.any(|a| a.contains("--disable-features") && a.contains("Translate")));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_build_args_headless_with_extensions_skips_headless_flag() {
let opts = LaunchOptions {
headless: true,
extensions: Some(vec!["/tmp/my-ext".to_string()]),
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(
!result.args.iter().any(|a| a.contains("--headless")),
"headless flag should be omitted when extensions are present"
);
assert!(
!result.args.iter().any(|a| a.contains("--window-size")),
"window-size should be omitted when extensions force headed mode"
);
assert!(result
.args
.iter()
.any(|a| a.starts_with("--load-extension=")));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_build_args_headed_with_extensions_no_headless_flag() {
let opts = LaunchOptions {
headless: false,
extensions: Some(vec!["/tmp/my-ext".to_string()]),
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(
!result.args.iter().any(|a| a.contains("--headless")),
"headless flag should not be present in headed mode"
);
assert!(result
.args
.iter()
.any(|a| a.starts_with("--load-extension=")));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_chrome_process_drop_cleans_temp_dir() {
let dir = std::env::temp_dir().join(format!(
"agent-browser-chrome-drop-test-{}",
uuid::Uuid::new_v4()
));
let _ = std::fs::create_dir_all(&dir);
assert!(dir.exists());
{
// Simulate a ChromeProcess with a temp dir but a dummy child.
// We can't actually spawn Chrome here, but we can verify the Drop
// logic by creating a small helper process.
let child = spawn_noop_child();
let _process = ChromeProcess {
child,
ws_url: String::new(),
temp_user_data_dir: Some(dir.clone()),
};
// _process dropped here
}
assert!(!dir.exists(), "Temp dir should be cleaned up on drop");
}
}
+205 -7
View File
@@ -1,17 +1,31 @@
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::connect_async;
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<
@@ -26,35 +40,110 @@ pub struct CdpClient {
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> {
let (ws_stream, _) = connect_async(url)
.await
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
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::Close(_)) => break,
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(_) => break,
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,
};
@@ -74,6 +163,33 @@ impl CdpClient {
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 {
@@ -81,7 +197,9 @@ impl CdpClient {
next_id: AtomicU64::new(1),
pending,
event_tx,
raw_tx,
_reader_handle: reader_handle,
_keepalive_handle: keepalive_handle,
})
}
@@ -97,7 +215,7 @@ impl CdpClient {
id,
method: method.to_string(),
params,
session_id: session_id.map(|s| s.to_string()),
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
};
let json = serde_json::to_string(&cmd)
@@ -138,6 +256,21 @@ impl CdpClient {
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,
@@ -160,4 +293,69 @@ impl CdpClient {
) -> 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(),
]
);
}
}
+2
View File
@@ -1,3 +1,5 @@
pub mod chrome;
pub mod client;
pub mod discovery;
pub mod lightpanda;
pub mod types;
+50 -1
View File
@@ -1,6 +1,51 @@
use serde::{Deserialize, Serialize};
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
// ---------------------------------------------------------------------------
@@ -215,6 +260,7 @@ pub struct RemoteObject {
pub object_id: Option<String>,
pub class_name: Option<String>,
pub unserializable_value: Option<String>,
pub preview: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -257,12 +303,14 @@ pub struct GetFullAXTreeResult {
#[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>,
@@ -532,6 +580,7 @@ pub struct BrowserVersionInfo {
/// 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"));
}
+13
View File
@@ -24,6 +24,19 @@ pub struct Cookie {
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,
+338 -29
View File
@@ -1,14 +1,20 @@
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();
@@ -16,15 +22,50 @@ pub async fn run_daemon(session: &str) {
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 {
@@ -33,44 +74,140 @@ pub async fn run_daemon(session: &str) {
}
}
let result = run_socket_server(&socket_path, session).await;
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
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);
}
}
let _ = fs::remove_file(&socket_path);
// 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 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 Err(e) = result {
eprintln!("Daemon error: {}", e);
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
process::exit(1);
}
}
#[cfg(unix)]
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
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 state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
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).await;
handle_connection(stream, state, reset_tx, sf).await;
});
}
Err(e) => {
eprintln!("Accept error: {}", 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 {
@@ -85,36 +222,83 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
#[cfg(windows)]
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
stream_server: Option<Arc<StreamServer>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::TcpListener;
let port = get_port_for_session(session);
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.map_err(|e| format!("Failed to bind TCP: {}", e))?;
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, port.to_string());
let _ = fs::write(&port_path, actual_port.to_string());
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
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).await;
handle_connection(stream, state, reset_tx, sf).await;
});
}
Err(e) => {
eprintln!("Accept error: {}", 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 {
@@ -129,8 +313,12 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
Ok(())
}
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
where
async fn handle_connection<S>(
stream: S,
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
stream_file_cleanup: Option<PathBuf>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
@@ -165,6 +353,10 @@ where
}
};
if let Some(ref tx) = idle_reset_tx {
let _ = tx.try_send(());
}
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let response = {
@@ -179,6 +371,9 @@ where
}
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);
}
@@ -201,21 +396,25 @@ async fn shutdown_signal() {
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to install SIGINT handler: {}", 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) => {
eprintln!("Failed to install SIGTERM handler: {}", 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) => {
eprintln!("Failed to install SIGHUP handler: {}", e);
let _ = writeln!(std::io::stderr(), "Failed to install SIGHUP handler: {}", e);
process::exit(1);
}
};
@@ -230,7 +429,7 @@ async fn shutdown_signal() {
#[cfg(windows)]
{
if let Err(e) = signal::ctrl_c().await {
eprintln!("Failed to install Ctrl+C handler: {}", e);
let _ = writeln!(std::io::stderr(), "Failed to install Ctrl+C handler: {}", e);
process::exit(1);
}
}
@@ -258,9 +457,119 @@ fn get_daemon_socket_dir() -> PathBuf {
#[cfg(windows)]
fn get_port_for_session(session: &str) -> u16 {
let mut hash: i64 = 0;
for b in session.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(b as i64);
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
),
}
}
49152 + (hash.unsigned_abs() % 16383) as u16
}
+79
View File
@@ -101,6 +101,21 @@ pub fn diff_screenshot(
/// 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;
@@ -192,4 +207,68 @@ mod tests {
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
+496 -99
View File
@@ -12,6 +12,7 @@ pub struct RefEntry {
pub name: String,
pub nth: Option<usize>,
pub selector: Option<String>,
pub frame_id: Option<String>,
}
pub struct RefMap {
@@ -34,6 +35,18 @@ impl RefMap {
role: &str,
name: &str,
nth: Option<usize>,
) {
self.add_with_frame(ref_id, backend_node_id, role, name, nth, None);
}
pub fn add_with_frame(
&mut self,
ref_id: String,
backend_node_id: Option<i64>,
role: &str,
name: &str,
nth: Option<usize>,
frame_id: Option<&str>,
) {
self.map.insert(
ref_id,
@@ -43,6 +56,28 @@ impl RefMap {
name: name.to_string(),
nth,
selector: None,
frame_id: frame_id.map(|s| s.to_string()),
},
);
}
pub fn add_selector(
&mut self,
ref_id: String,
selector: String,
role: &str,
name: &str,
nth: Option<usize>,
) {
self.map.insert(
ref_id,
RefEntry {
backend_node_id: None,
role: role.to_string(),
name: name.to_string(),
nth,
selector: Some(selector),
frame_id: None,
},
);
}
@@ -51,6 +86,23 @@ impl RefMap {
self.map.get(ref_id)
}
pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> {
let mut entries = self
.map
.iter()
.map(|(ref_id, entry)| (ref_id.clone(), entry.clone()))
.collect::<Vec<_>>();
entries.sort_by_key(|(ref_id, _)| {
ref_id
.strip_prefix('e')
.and_then(|n| n.parse::<usize>().ok())
.unwrap_or(usize::MAX)
});
entries
}
pub fn clear(&mut self) {
self.map.clear();
self.next_ref = 1;
@@ -95,14 +147,19 @@ pub async fn resolve_element_center(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(f64, f64), String> {
iframe_sessions: &HashMap<String, String>,
) -> Result<(f64, f64, String), String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
let effective_session_id =
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
// Try cached backend_node_id first (fast path)
if let Some(backend_node_id) = entry.backend_node_id {
let result: DomGetBoxModelResult = client
let result: Result<DomGetBoxModelResult, String> = client
.send_command_typed(
"DOM.getBoxModel",
&DomGetBoxModelParams {
@@ -110,19 +167,46 @@ pub async fn resolve_element_center(
node_id: None,
object_id: None,
},
Some(session_id),
Some(effective_session_id),
)
.await?;
.await;
return Ok(box_model_center(&result.model));
if let Ok(r) = result {
let (x, y) = box_model_center(&r.model);
return Ok((x, y, effective_session_id.to_string()));
}
// backend_node_id is stale; re-query the accessibility tree below
}
// Fallback: use role/name to find via JS
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
// Fallback: re-query the accessibility tree to find a fresh node by role/name
let fresh_id = find_node_id_by_role_name(
client,
session_id,
&entry.role,
&entry.name,
entry.nth,
entry.frame_id.as_deref(),
iframe_sessions,
)
.await?;
let result: DomGetBoxModelResult = client
.send_command_typed(
"DOM.getBoxModel",
&DomGetBoxModelParams {
backend_node_id: Some(fresh_id),
node_id: None,
object_id: None,
},
Some(effective_session_id),
)
.await?;
let (x, y) = box_model_center(&result.model);
return Ok((x, y, effective_session_id.to_string()));
}
// CSS selector
resolve_by_selector(client, session_id, selector_or_ref).await
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
Ok((x, y, session_id.to_string()))
}
pub async fn resolve_element_object_id(
@@ -130,14 +214,19 @@ pub async fn resolve_element_object_id(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<String, String> {
iframe_sessions: &HashMap<String, String>,
) -> Result<(String, String), String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
let effective_session_id =
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
// Try cached backend_node_id first (fast path)
if let Some(backend_node_id) = entry.backend_node_id {
let result: DomResolveNodeResult = client
let result: Result<DomResolveNodeResult, String> = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
@@ -145,22 +234,49 @@ pub async fn resolve_element_object_id(
node_id: None,
object_group: Some("agent-browser".to_string()),
},
Some(session_id),
Some(effective_session_id),
)
.await?;
.await;
return result
.object
.object_id
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
if let Ok(r) = result {
if let Some(object_id) = r.object.object_id {
return Ok((object_id, effective_session_id.to_string()));
}
}
// backend_node_id is stale; re-query the accessibility tree below
}
// Fallback: re-query the accessibility tree to find a fresh node by role/name
let fresh_id = find_node_id_by_role_name(
client,
session_id,
&entry.role,
&entry.name,
entry.nth,
entry.frame_id.as_deref(),
iframe_sessions,
)
.await?;
let result: DomResolveNodeResult = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
backend_node_id: Some(fresh_id),
node_id: None,
object_group: Some("agent-browser".to_string()),
},
Some(effective_session_id),
)
.await?;
let object_id = result
.object
.object_id
.ok_or_else(|| format!("No objectId for ref {}", ref_id))?;
return Ok((object_id, effective_session_id.to_string()));
}
// CSS selector fallback
let js = format!(
"document.querySelector({})",
serde_json::to_string(selector_or_ref).unwrap_or_default()
);
// Selector fallback (CSS or XPath)
let js = build_find_element_js(selector_or_ref);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
@@ -173,63 +289,149 @@ pub async fn resolve_element_object_id(
)
.await?;
result
let object_id = result
.result
.object_id
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))?;
Ok((object_id, session_id.to_string()))
}
async fn resolve_by_role_name(
/// Determine which CDP session and parameters to use for an AX tree query.
/// Cross-origin iframes have a dedicated session (no frameId needed);
/// same-origin iframes use the parent session with a frameId parameter.
pub(super) fn resolve_ax_session<'a>(
frame_id: Option<&str>,
session_id: &'a str,
iframe_sessions: &'a HashMap<String, String>,
) -> (serde_json::Value, &'a str) {
if let Some(frame_id) = frame_id {
if let Some(iframe_sid) = iframe_sessions.get(frame_id) {
(serde_json::json!({}), iframe_sid.as_str())
} else {
(serde_json::json!({ "frameId": frame_id }), session_id)
}
} else {
(serde_json::json!({}), session_id)
}
}
/// Resolve the effective CDP session for an element's frame.
/// If the element's frame_id has a dedicated cross-origin iframe session, return it.
/// Otherwise, return the parent session.
fn resolve_frame_session<'a>(
frame_id: Option<&str>,
session_id: &'a str,
iframe_sessions: &'a HashMap<String, String>,
) -> &'a str {
frame_id
.and_then(|fid| iframe_sessions.get(fid))
.map(|s| s.as_str())
.unwrap_or(session_id)
}
/// Re-query the accessibility tree to find a node matching role+name+nth,
/// returning its fresh backendDOMNodeId. This uses the same data source
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
/// so role/name matching is guaranteed to be consistent.
async fn find_node_id_by_role_name(
client: &CdpClient,
session_id: &str,
role: &str,
name: &str,
nth: Option<usize>,
) -> Result<(f64, f64), String> {
frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>,
) -> Result<i64, String> {
let (ax_params, effective_session_id) =
resolve_ax_session(frame_id, session_id, iframe_sessions);
let ax_tree: GetFullAXTreeResult = client
.send_command_typed(
"Accessibility.getFullAXTree",
&ax_params,
Some(effective_session_id),
)
.await?;
let nth_index = nth.unwrap_or(0);
let js = format!(
let mut match_count: usize = 0;
for node in &ax_tree.nodes {
if node.ignored.unwrap_or(false) {
continue;
}
let node_role = extract_ax_string(&node.role);
let node_name = extract_ax_string(&node.name);
if node_role == role && node_name == name {
if match_count == nth_index {
return node.backend_d_o_m_node_id.ok_or_else(|| {
format!(
"AX node has no backendDOMNodeId for role={} name={}",
role, name
)
});
}
match_count += 1;
}
}
Err(format!(
"Could not locate element with role={} name={}",
role, name
))
}
fn extract_ax_string(value: &Option<AXValue>) -> String {
match value {
Some(v) => match &v.value {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
_ => String::new(),
},
None => String::new(),
}
}
/// Build a JS expression that finds a DOM element by CSS selector or XPath.
fn build_find_element_js(selector: &str) -> String {
if let Some(xpath) = selector.strip_prefix("xpath=") {
format!(
"document.evaluate({}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
serde_json::to_string(xpath).unwrap_or_default()
)
} else {
format!(
"document.querySelector({})",
serde_json::to_string(selector).unwrap_or_default()
)
}
}
/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
fn build_count_elements_js(selector: &str) -> String {
if let Some(xpath) = selector.strip_prefix("xpath=") {
format!(
"document.evaluate({}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength",
serde_json::to_string(xpath).unwrap_or_default()
)
} else {
format!(
"document.querySelectorAll({}).length",
serde_json::to_string(selector).unwrap_or_default()
)
}
}
fn build_selector_js(selector: &str) -> String {
let find_expr = build_find_element_js(selector);
format!(
r#"(() => {{
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
const matches = [];
let node;
while (node = walker.nextNode()) {{
const r = node.getAttribute('role') || node.tagName.toLowerCase();
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
if (r === {role} && n === {name}) matches.push(node);
}}
const el = matches[{nth}];
const el = {find_expr};
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
}})()"#,
role = serde_json::to_string(role).unwrap_or_default(),
name = serde_json::to_string(name).unwrap_or_default(),
nth = nth_index,
);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let val = result.result.value.unwrap_or(Value::Null);
let x = val.get("x").and_then(|v| v.as_f64());
let y = val.get("y").and_then(|v| v.as_f64());
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(format!(
"Could not locate element with role={} name={}",
role, name
)),
}
)
}
async fn resolve_by_selector(
@@ -237,15 +439,7 @@ async fn resolve_by_selector(
session_id: &str,
selector: &str,
) -> Result<(f64, f64), String> {
let js = format!(
r#"(() => {{
const el = document.querySelector({sel});
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
}})()"#,
sel = serde_json::to_string(selector).unwrap_or_default(),
);
let js = build_selector_js(selector);
let result: EvaluateResult = client
.send_command_typed(
@@ -285,8 +479,16 @@ pub async fn get_element_text(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -299,7 +501,7 @@ pub async fn get_element_text(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -316,8 +518,16 @@ pub async fn get_element_attribute(
ref_map: &RefMap,
selector_or_ref: &str,
attribute: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<Value, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -332,7 +542,7 @@ pub async fn get_element_attribute(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -344,8 +554,16 @@ pub async fn is_element_visible(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<bool, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -365,7 +583,7 @@ pub async fn is_element_visible(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -381,8 +599,16 @@ pub async fn is_element_enabled(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<bool, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -394,7 +620,7 @@ pub async fn is_element_enabled(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -410,20 +636,61 @@ pub async fn is_element_checked(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<bool, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
// Mirrors Playwright's getChecked() with follow-label retargeting:
// 1. If element is a native checkbox/radio input, return .checked
// 2. If element has an ARIA checked role, return aria-checked
// 3. Follow label → input association (label.control)
// 4. Check for nested checkbox/radio input as last resort
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return !!this.checked; }".to_string(),
function_declaration: r#"function() {
var el = this;
// Native checkbox/radio input
var tag = el.tagName && el.tagName.toUpperCase();
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
return el.checked;
}
// ARIA role-based checked state
var role = el.getAttribute && el.getAttribute('role');
var ariaCheckedRoles = ['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'];
if (role && ariaCheckedRoles.indexOf(role) !== -1) {
return el.getAttribute('aria-checked') === 'true';
}
// Follow label association (Playwright follow-label retarget)
var label = el;
if (tag !== 'LABEL') {
label = el.closest && el.closest('label');
}
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
var ctrl = label.control;
if (ctrl.type === 'checkbox' || ctrl.type === 'radio') {
return ctrl.checked;
}
}
// Check for nested native input
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
if (input) return input.checked;
return false;
}"#.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -439,8 +706,16 @@ pub async fn get_element_inner_text(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -452,7 +727,7 @@ pub async fn get_element_inner_text(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -468,8 +743,16 @@ pub async fn get_element_inner_html(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -481,7 +764,7 @@ pub async fn get_element_inner_html(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -497,8 +780,16 @@ pub async fn get_element_input_value(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -512,7 +803,7 @@ pub async fn get_element_input_value(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -529,8 +820,16 @@ pub async fn set_element_value(
ref_map: &RefMap,
selector_or_ref: &str,
value: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let js = format!(
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
@@ -547,7 +846,7 @@ pub async fn set_element_value(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -559,8 +858,16 @@ pub async fn get_element_bounding_box(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<Value, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
@@ -576,7 +883,7 @@ pub async fn get_element_bounding_box(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -591,10 +898,7 @@ pub async fn get_element_count(
session_id: &str,
selector: &str,
) -> Result<i64, String> {
let js = format!(
"document.querySelectorAll({}).length",
serde_json::to_string(selector).unwrap_or_default()
);
let js = build_count_elements_js(selector);
let result: EvaluateResult = client
.send_command_typed(
@@ -617,8 +921,16 @@ pub async fn get_element_styles(
ref_map: &RefMap,
selector_or_ref: &str,
properties: Option<Vec<String>>,
iframe_sessions: &HashMap<String, String>,
) -> Result<Value, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let js = match properties {
Some(props) => {
@@ -656,7 +968,7 @@ pub async fn get_element_styles(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -701,6 +1013,47 @@ mod tests {
assert!(map.get("e2").is_none());
}
#[test]
fn test_build_selector_js_css() {
let js = build_selector_js("#submit-btn");
assert!(js.contains("document.querySelector(\"#submit-btn\")"));
assert!(!js.contains("document.evaluate"));
}
#[test]
fn test_build_selector_js_xpath() {
let js = build_selector_js("xpath=//button[@id='ok']");
assert!(js.contains("document.evaluate(\"//button[@id='ok']\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)"));
assert!(!js.contains("document.querySelector"));
}
#[test]
fn test_build_selector_js_xpath_empty() {
let js = build_selector_js("xpath=");
assert!(js.contains("document.evaluate"));
}
#[test]
fn test_build_selector_js_not_xpath_prefix() {
// "xpath" without "=" should be treated as CSS selector
let js = build_selector_js("xpath//div");
assert!(js.contains("document.querySelector"));
}
#[test]
fn test_build_count_elements_js_css() {
let js = build_count_elements_js(".item");
assert!(js.contains("document.querySelectorAll(\".item\").length"));
assert!(!js.contains("document.evaluate"));
}
#[test]
fn test_build_count_elements_js_xpath() {
let js = build_count_elements_js("xpath=//li");
assert!(js.contains("document.evaluate(\"//li\", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength"));
assert!(!js.contains("querySelectorAll"));
}
#[test]
fn test_box_model_center() {
let model = BoxModel {
@@ -715,4 +1068,48 @@ mod tests {
assert!((x - 60.0).abs() < 0.01);
assert!((y - 40.0).abs() < 0.01);
}
// -----------------------------------------------------------------------
// resolve_frame_session tests (Issue #925)
// Cross-origin iframe elements must resolve to the dedicated session.
// -----------------------------------------------------------------------
#[test]
fn test_cross_origin_element_uses_dedicated_session() {
let mut iframe_sessions = HashMap::new();
iframe_sessions.insert(
"cross-origin-frame".to_string(),
"iframe-session".to_string(),
);
let session = resolve_frame_session(
Some("cross-origin-frame"),
"parent-session",
&iframe_sessions,
);
assert_eq!(session, "iframe-session");
}
#[test]
fn test_same_origin_element_uses_parent_session() {
let iframe_sessions = HashMap::new();
let session = resolve_frame_session(
Some("same-origin-frame"),
"parent-session",
&iframe_sessions,
);
assert_eq!(session, "parent-session");
}
#[test]
fn test_main_frame_element_uses_parent_session() {
let iframe_sessions = HashMap::new();
let session = resolve_frame_session(None, "parent-session", &iframe_sessions);
assert_eq!(session, "parent-session");
}
}
+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);
}
}
+559 -83
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde_json::Value;
use super::cdp::client::CdpClient;
@@ -11,9 +13,17 @@ pub async fn click(
selector_or_ref: &str,
button: &str,
click_count: i32,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
dispatch_click(client, session_id, x, y, button, click_count).await
let (x, y, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
}
pub async fn dblclick(
@@ -21,8 +31,18 @@ pub async fn dblclick(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
click(client, session_id, ref_map, selector_or_ref, "left", 2).await
click(
client,
session_id,
ref_map,
selector_or_ref,
"left",
2,
iframe_sessions,
)
.await
}
pub async fn hover(
@@ -30,8 +50,16 @@ pub async fn hover(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
let (x, y, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
@@ -46,7 +74,7 @@ pub async fn hover(
delta_y: None,
modifiers: None,
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
Ok(())
@@ -58,8 +86,16 @@ pub async fn fill(
ref_map: &RefMap,
selector_or_ref: &str,
value: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
// Focus the element
client
@@ -72,7 +108,7 @@ pub async fn fill(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -92,11 +128,11 @@ pub async fn fill(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
// Insert text
// Insert text (keyboard input dispatched at page level, use parent session_id)
client
.send_command_typed::<_, Value>(
"Input.insertText",
@@ -110,6 +146,7 @@ pub async fn fill(
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn type_text(
client: &CdpClient,
session_id: &str,
@@ -118,8 +155,16 @@ pub async fn type_text(
text: &str,
clear: bool,
delay_ms: Option<u64>,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
// Focus
client
@@ -132,7 +177,7 @@ pub async fn type_text(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -152,50 +197,73 @@ pub async fn type_text(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
}
type_text_into_active_context(client, session_id, text, delay_ms).await
}
pub async fn type_text_into_active_context(
client: &CdpClient,
session_id: &str,
text: &str,
delay_ms: Option<u64>,
) -> Result<(), String> {
let delay = delay_ms.unwrap_or(0);
for ch in text.chars() {
let text_str = ch.to_string();
let (key, code, key_code) = char_to_key_info(ch);
if matches!(ch, '\n' | '\r' | '\t') {
let (key, code, key_code) = char_to_key_info(ch);
let text_str = key_text(&key);
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key.clone()),
code: Some(code.clone()),
text: text_str.clone(),
unmodified_text: text_str,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key.clone()),
code: Some(code.clone()),
text: Some(text_str.clone()),
unmodified_text: Some(text_str.clone()),
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyUp".to_string(),
key: Some(key),
code: Some(code),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyUp".to_string(),
key: Some(key),
code: Some(code),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
} else {
// VS Code/Electron webviews reject repeated dispatchKeyEvent calls
// carrying printable `text`. Insert printable characters directly
// and reserve key events for controls like Enter and Tab.
client
.send_command_typed::<_, Value>(
"Input.insertText",
&InsertTextParams {
text: ch.to_string(),
},
Some(session_id),
)
.await?;
}
if delay > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
@@ -206,8 +274,33 @@ pub async fn type_text(
}
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
press_key_with_modifiers(client, session_id, key, None).await
}
/// Dispatch a keyDown+keyUp sequence for `key` with an optional CDP modifier bitmask.
///
/// Modifier values follow the CDP `Input.dispatchKeyEvent` spec:
/// 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
///
/// Callers that need a platform-appropriate modifier (e.g. Cmd on macOS,
/// Ctrl elsewhere) must choose the value themselves -- see `cfg!(target_os)`.
pub async fn press_key_with_modifiers(
client: &CdpClient,
session_id: &str,
key: &str,
modifiers: Option<i32>,
) -> Result<(), String> {
let (key_name, code, key_code) = named_key_info(key);
// Suppress text insertion when Control (2) or Meta (4) modifiers are active,
// since these are command chords (e.g. Ctrl+A = select-all), not text input.
let has_command_modifier = modifiers.is_some_and(|m| m & (2 | 4) != 0);
let text = if has_command_modifier {
None
} else {
key_text(&key_name)
};
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
@@ -215,11 +308,11 @@ pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Resul
event_type: "keyDown".to_string(),
key: Some(key_name.clone()),
code: Some(code.clone()),
text: None,
unmodified_text: None,
text: text.clone(),
unmodified_text: text.clone(),
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
modifiers,
},
Some(session_id),
)
@@ -236,7 +329,7 @@ pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Resul
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
modifiers,
},
Some(session_id),
)
@@ -252,9 +345,11 @@ pub async fn scroll(
selector_or_ref: Option<&str>,
delta_x: f64,
delta_y: f64,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
if let Some(sel) = selector_or_ref {
let object_id = resolve_element_object_id(client, session_id, ref_map, sel).await?;
let (object_id, effective_session_id) =
resolve_element_object_id(client, session_id, ref_map, sel, iframe_sessions).await?;
let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
client
.send_command_typed::<_, Value>(
@@ -275,7 +370,7 @@ pub async fn scroll(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
} else {
@@ -301,8 +396,16 @@ pub async fn select_option(
ref_map: &RefMap,
selector_or_ref: &str,
values: &[String],
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let js = r#"function(vals) {
const options = Array.from(this.options);
@@ -326,7 +429,7 @@ pub async fn select_option(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -338,11 +441,49 @@ pub async fn check(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let is_checked =
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
let is_checked = super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
if !is_checked {
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
click(
client,
session_id,
ref_map,
selector_or_ref,
"left",
1,
iframe_sessions,
)
.await?;
// Verify the click changed the state (Playwright parity: _setChecked re-checks).
// If the coordinate-based click missed (e.g. hidden input, overlay), retry
// with a JS .click() on the element and its associated input.
if !super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?
{
js_click_checkbox(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
}
}
Ok(())
}
@@ -352,22 +493,132 @@ pub async fn uncheck(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let is_checked =
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
let is_checked = super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
if is_checked {
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
click(
client,
session_id,
ref_map,
selector_or_ref,
"left",
1,
iframe_sessions,
)
.await?;
// Same verify-and-retry as check().
if super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?
{
js_click_checkbox(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
}
}
Ok(())
}
/// Fallback for when the coordinate-based CDP click did not toggle the
/// checkbox/radio state. This mirrors how Playwright dispatches clicks
/// through the DOM rather than via raw Input.dispatchMouseEvent coordinates.
///
/// Uses the same follow-label resolution as `is_element_checked`:
/// 1. If the element is a native input → `.click()` it directly.
/// 2. If the element is inside a `<label>` → `.click()` the label's `.control`.
/// 3. If the element has a nested `<input>` → `.click()` that input.
/// 4. Otherwise → `.click()` the element itself (handles ARIA role controls).
async fn js_click_checkbox(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let js = r#"function() {
var el = this;
var tag = el.tagName && el.tagName.toUpperCase();
// 1. Native input — click it directly
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
el.click();
return;
}
// 2. Follow label → control association
var label = tag === 'LABEL' ? el : (el.closest && el.closest('label'));
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
label.control.click();
return;
}
// 3. Nested native input
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
if (input) {
input.click();
return;
}
// 4. ARIA role control — click the element itself
el.click();
}"#;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(())
}
pub async fn focus(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
@@ -379,7 +630,7 @@ pub async fn focus(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -391,8 +642,16 @@ pub async fn clear(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
@@ -410,7 +669,7 @@ pub async fn clear(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -422,8 +681,16 @@ pub async fn select_all(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
@@ -447,7 +714,7 @@ pub async fn select_all(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -459,8 +726,16 @@ pub async fn scroll_into_view(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
@@ -474,7 +749,7 @@ pub async fn scroll_into_view(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -488,8 +763,16 @@ pub async fn dispatch_event(
selector_or_ref: &str,
event_type: &str,
event_init: Option<&Value>,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let init_json = event_init
.map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
@@ -511,7 +794,7 @@ pub async fn dispatch_event(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -523,8 +806,16 @@ pub async fn highlight(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
@@ -545,7 +836,7 @@ pub async fn highlight(
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -557,8 +848,16 @@ pub async fn tap_touch(
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
let (x, y, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command(
@@ -567,7 +866,7 @@ pub async fn tap_touch(
"type": "touchStart",
"touchPoints": [{ "x": x, "y": y }],
})),
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -578,7 +877,7 @@ pub async fn tap_touch(
"type": "touchEnd",
"touchPoints": [],
})),
Some(session_id),
Some(&effective_session_id),
)
.await?;
@@ -666,15 +965,75 @@ fn char_to_key_info(ch: char) -> (String, String, i32) {
' ' => (" ".to_string(), "Space".to_string(), 32),
_ => {
let key = ch.to_string();
let code = if ch.is_ascii_alphabetic() {
format!("Key{}", ch.to_uppercase())
if ch.is_ascii_alphabetic() {
// For letters the Windows VK code equals the uppercase ASCII value.
let upper = ch.to_ascii_uppercase();
let code = format!("Key{}", upper);
let key_code = upper as i32;
(key, code, key_code)
} else if ch.is_ascii_digit() {
format!("Digit{}", ch)
let code = format!("Digit{}", ch);
let key_code = ch as i32;
(key, code, key_code)
} else {
String::new()
};
let key_code = ch as i32;
(key, code, key_code)
let (code, key_code) = punctuation_key_info(ch);
(key, code.to_string(), key_code)
}
}
}
}
/// Return the DOM `KeyboardEvent.code` value and Windows virtual-key code for
/// a punctuation / symbol character assuming a US keyboard layout.
///
/// The Windows virtual-key codes (VK_OEM_*) differ from ASCII values for
/// punctuation. Using the raw ASCII code would misidentify characters e.g.
/// '.' (ASCII 46) collides with VK_DELETE (0x2E = 46), causing the period to
/// be swallowed.
fn punctuation_key_info(ch: char) -> (&'static str, i32) {
match ch {
// VK_OEM_1 (0xBA = 186) — ";:" key on US layout
';' | ':' => ("Semicolon", 186),
// VK_OEM_PLUS (0xBB = 187) — "=+" key
'=' | '+' => ("Equal", 187),
// VK_OEM_COMMA (0xBC = 188) — ",<" key
',' | '<' => ("Comma", 188),
// VK_OEM_MINUS (0xBD = 189) — "-_" key
'-' | '_' => ("Minus", 189),
// VK_OEM_PERIOD (0xBE = 190) — ".>" key
'.' | '>' => ("Period", 190),
// VK_OEM_2 (0xBF = 191) — "/?" key
'/' | '?' => ("Slash", 191),
// VK_OEM_3 (0xC0 = 192) — "`~" key
'`' | '~' => ("Backquote", 192),
// VK_OEM_4 (0xDB = 219) — "[{" key
'[' | '{' => ("BracketLeft", 219),
// VK_OEM_5 (0xDC = 220) — "\\|" key
'\\' | '|' => ("Backslash", 220),
// VK_OEM_6 (0xDD = 221) — "]}" key
']' | '}' => ("BracketRight", 221),
// VK_OEM_7 (0xDE = 222) — "'\""" key
'\'' | '"' => ("Quote", 222),
_ => ("", 0),
}
}
/// Return the `text` value that CDP `Input.dispatchKeyEvent` needs on the
/// `keyDown` event so that Chrome performs the default action for the key.
/// For example Enter needs `"\r"` to actually submit a form, and Tab needs
/// `"\t"` to move focus. Non-printable / navigation keys return `None`.
fn key_text(key_name: &str) -> Option<String> {
match key_name {
"Enter" => Some("\r".to_string()),
"Tab" => Some("\t".to_string()),
" " => Some(" ".to_string()),
_ => {
// Single printable characters carry themselves as text.
if key_name.len() == 1 {
Some(key_name.to_string())
} else {
None
}
}
}
}
@@ -705,3 +1064,120 @@ fn named_key_info(key: &str) -> (String, String, i32) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that `char_to_key_info` returns the correct (key, code,
/// windowsVirtualKeyCode) triple for every character in Playwright's
/// USKeyboardLayout. The expected values below are taken verbatim from
/// playwright-core/lib/server/usKeyboardLayout.js so that any drift from
/// Playwright's behaviour is caught immediately.
#[test]
fn test_char_to_key_info_matches_playwright_layout() {
// (character, expected_code, expected_vk_code)
let cases: &[(char, &str, i32)] = &[
// Letters VK code must equal the uppercase ASCII value.
('a', "KeyA", 65),
('z', "KeyZ", 90),
('A', "KeyA", 65),
// Digits
('0', "Digit0", 48),
('9', "Digit9", 57),
// Punctuation these are the values from Playwright's layout.
// The bug that prompted this test sent '.' as VK 46 (= VK_DELETE).
('.', "Period", 190),
(',', "Comma", 188),
('/', "Slash", 191),
(';', "Semicolon", 186),
('\'', "Quote", 222),
('[', "BracketLeft", 219),
(']', "BracketRight", 221),
('\\', "Backslash", 220),
('`', "Backquote", 192),
('-', "Minus", 189),
('=', "Equal", 187),
// Shifted variants produced by the same physical keys.
('>', "Period", 190),
('<', "Comma", 188),
('?', "Slash", 191),
(':', "Semicolon", 186),
('"', "Quote", 222),
('{', "BracketLeft", 219),
('}', "BracketRight", 221),
('|', "Backslash", 220),
('~', "Backquote", 192),
('_', "Minus", 189),
('+', "Equal", 187),
// Whitespace / control
(' ', "Space", 32),
('\n', "Enter", 13),
('\t', "Tab", 9),
];
for &(ch, expected_code, expected_vk) in cases {
let (key, code, vk) = char_to_key_info(ch);
assert_eq!(
code, expected_code,
"char {:?}: expected code {:?}, got {:?}",
ch, expected_code, code
);
assert_eq!(
vk, expected_vk,
"char {:?}: expected VK {}, got {} (ASCII would be {})",
ch, expected_vk, vk, ch as i32
);
// key should be the character itself (except control chars).
if !ch.is_control() {
assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch);
}
}
}
/// Regression test: period must NEVER map to VK 46 (VK_DELETE).
#[test]
fn test_period_is_not_vk_delete() {
let (_, _, vk) = char_to_key_info('.');
assert_ne!(
vk, 46,
"Period must not use VK code 46 (VK_DELETE); expected 190 (VK_OEM_PERIOD)"
);
assert_eq!(vk, 190);
}
/// Characters outside the US keyboard layout should return (key, "", 0)
/// so that `type_text` falls back to `Input.insertText`.
#[test]
fn test_unmapped_chars_return_zero_keycode() {
for ch in ['@', '#', '$', '%', '^', '&', '*', '(', ')', '€', '£', '你'] {
let (key, code, vk) = char_to_key_info(ch);
assert_eq!(
code, "",
"char {:?}: unmapped char should have empty code, got {:?}",
ch, code
);
assert_eq!(
vk, 0,
"char {:?}: unmapped char should have VK 0, got {}",
ch, vk
);
assert_eq!(key, ch.to_string());
}
}
#[test]
fn test_key_text_returns_correct_text_for_special_keys() {
assert_eq!(key_text("Enter"), Some("\r".to_string()));
assert_eq!(key_text("Tab"), Some("\t".to_string()));
assert_eq!(key_text(" "), Some(" ".to_string()));
// Single printable characters carry themselves.
assert_eq!(key_text("a"), Some("a".to_string()));
assert_eq!(key_text("Z"), Some("Z".to_string()));
// Non-printable named keys return None.
assert_eq!(key_text("Escape"), None);
assert_eq!(key_text("ArrowUp"), None);
assert_eq!(key_text("Backspace"), None);
assert_eq!(key_text("Delete"), None);
}
}
+4
View File
@@ -15,6 +15,8 @@ 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;
@@ -31,6 +33,8 @@ pub mod snapshot;
#[allow(dead_code)]
pub mod state;
#[allow(dead_code)]
pub mod stealth;
#[allow(dead_code)]
pub mod storage;
#[allow(dead_code)]
pub mod stream;
+287 -14
View File
@@ -184,7 +184,7 @@ pub async fn install_domain_filter_script(
const OrigWS = window.WebSocket;
window.WebSocket = function(url, protocols) {{
try {{
const u = new URL(url);
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);
@@ -233,15 +233,16 @@ pub async fn install_domain_filter_script(
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(json!({
"patterns": [{ "urlPattern": "*" }]
})),
Some(session_id),
)
.send_command("Fetch.enable", Some(params), Some(session_id))
.await?;
Ok(())
}
@@ -253,12 +254,103 @@ 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).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
// ---------------------------------------------------------------------------
@@ -267,6 +359,7 @@ pub async fn install_domain_filter(
pub struct ConsoleEntry {
pub level: String,
pub text: String,
pub args: Vec<Value>,
}
#[derive(Debug, Clone)]
@@ -292,13 +385,14 @@ impl EventTracker {
}
}
pub fn add_console(&mut self, level: &str, text: &str) {
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,
});
}
@@ -320,13 +414,25 @@ impl EventTracker {
});
}
pub fn clear_console(&mut self) {
self.console_entries.clear();
}
pub fn get_console_json(&self) -> Value {
let entries: Vec<Value> = self
let messages: Vec<Value> = self
.console_entries
.iter()
.map(|e| json!({ "level": e.level, "text": e.text }))
.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!({ "entries": entries })
json!({ "messages": messages })
}
pub fn get_errors_json(&self) -> Value {
@@ -390,10 +496,177 @@ mod tests {
#[test]
fn test_event_tracker() {
let mut tracker = EventTracker::new();
tracker.add_console("log", "hello");
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");
}
}
+76 -1
View File
@@ -9,6 +9,38 @@ 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",
@@ -140,6 +172,7 @@ const DOCUMENTED_ACTIONS: &[&str] = &[
"route",
"unroute",
"requests",
"request_detail",
"credentials",
"auth_save",
"auth_login",
@@ -311,7 +344,7 @@ fn minimal_command(action: &str, id: &str) -> Value {
obj.insert("script".to_string(), json!("h => h"));
}
"drag" => {
obj.insert("selector".to_string(), json!("body"));
obj.insert("source".to_string(), json!("body"));
obj.insert("target".to_string(), json!("body"));
}
"swipe" => {
@@ -424,6 +457,7 @@ async fn test_credentials_list_without_browser() {
#[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);
@@ -439,6 +473,7 @@ async fn test_auth_profile_name_validation() {
#[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",
@@ -499,6 +534,7 @@ async fn test_daemon_state_new_defaults() {
assert!(state.tracked_requests.is_empty());
assert!(state.active_frame_id.is_none());
assert!(state.webdriver_backend.is_none());
assert!(state.stream_client.is_none());
}
#[tokio::test]
@@ -510,6 +546,11 @@ async fn test_tracked_request_struct() {
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");
@@ -530,6 +571,11 @@ async fn test_request_tracking_state() {
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(),
@@ -537,6 +583,11 @@ async fn test_request_tracking_state() {
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);
@@ -554,6 +605,30 @@ async fn test_request_tracking_state() {
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();
+3 -2
View File
@@ -135,6 +135,7 @@ impl ActionPolicy {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[test]
fn test_policy_allow_whitelist() {
@@ -205,12 +206,12 @@ mod tests {
#[test]
fn test_confirm_actions_from_env() {
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
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"));
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
}
}
+586 -44
View File
@@ -1,28 +1,72 @@
//! Browser provider connections for remote CDP sessions.
//!
//! Supports Browserbase, Browser Use, and Kernel providers. Each provider
//! returns a CDP WebSocket URL for connecting via BrowserManager.
//! 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<(String, Option<ProviderSession>), String> {
pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
match provider_name.to_lowercase().as_str() {
"browserbase" => connect_browserbase().await,
"browser-use" | "browseruse" => connect_browser_use().await,
"kernel" => connect_kernel().await,
"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, browser-use, kernel",
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel, agentcore",
provider_name
)),
}
@@ -35,11 +79,13 @@ pub async fn close_provider_session(session: &ProviderSession) {
"browserbase" => {
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
let _ = client
.delete(format!(
.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;
}
@@ -58,6 +104,10 @@ pub async fn close_provider_session(session: &ProviderSession) {
.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")
@@ -73,6 +123,10 @@ pub async fn close_provider_session(session: &ProviderSession) {
.await;
}
}
"agentcore" => {
// AgentCore session cleanup is handled via signed DELETE request
let _ = close_agentcore_session(&session.session_id).await;
}
_ => {}
}
}
@@ -80,15 +134,13 @@ pub async fn close_provider_session(session: &ProviderSession) {
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 project_id = env::var("BROWSERBASE_PROJECT_ID")
.map_err(|_| "BROWSERBASE_PROJECT_ID 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)
.json(&json!({ "projectId": project_id }))
.header("content-type", "application/json")
.header("x-bb-api-key", &api_key)
.body("{}")
.send()
.await
.map_err(|e| format!("Browserbase request failed: {}", e))?;
@@ -131,62 +183,98 @@ async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), Stri
))
}
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")?;
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("https://api.browser-use.com/api/v2/browsers")
.post(&url)
.query(&[("token", &api_key)])
.header("Content-Type", "application/json")
.header("X-Browser-Use-API-Key", &api_key)
.json(&json!({}))
.json(&json!({
"ttl": ttl,
"stealth": stealth,
"browser": browser_type,
}))
.send()
.await
.map_err(|e| format!("Browser Use request failed: {}", e))?;
.map_err(|e| format!("Browserless request failed: {}", e))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
.map_err(|e| format!("Failed to read Browserless response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Browser Use API error ({}): {}",
"Browserless API error ({}): {}",
status.as_u16(),
body
));
}
let json: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?;
let session_id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("cdp_url")
.or_else(|| json.get("cdpUrl"))
let connect_url = json
.get("connect")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
.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((
ws_url,
connect_url,
Some(ProviderSession {
provider: "browser-use".to_string(),
session_id,
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").map_err(|_| "KERNEL_API_KEY environment variable is not set")?;
let api_key = env::var("KERNEL_API_KEY").ok();
let endpoint =
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
@@ -218,10 +306,11 @@ async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
}
let client = reqwest::Client::new();
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
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
@@ -272,3 +361,456 @@ async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
}),
))
}
// ============================================================================
// 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());
}
}
+217 -97
View File
@@ -1,12 +1,24 @@
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Command;
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 temp_dir: PathBuf,
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 {
@@ -14,8 +26,10 @@ impl RecordingState {
Self {
active: false,
output_path: String::new(),
temp_dir: PathBuf::new(),
frame_count: 0,
capture_task: None,
shared_frame_count: None,
cancel_tx: None,
}
}
}
@@ -25,34 +39,13 @@ pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value,
return Err("Recording already active".to_string());
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp_dir = std::env::temp_dir().join(format!("agent-browser-recording-{}", timestamp));
let _ = std::fs::create_dir_all(&temp_dir);
state.active = true;
state.output_path = path.to_string();
state.temp_dir = temp_dir;
state.frame_count = 0;
Ok(json!({ "started": true, "path": path }))
}
pub fn recording_add_frame(state: &mut RecordingState, frame_data: &[u8]) {
if !state.active {
return;
}
let frame_path = state
.temp_dir
.join(format!("frame_{:06}.jpg", state.frame_count));
let _ = std::fs::write(&frame_path, frame_data);
state.frame_count += 1;
}
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
if !state.active {
return Err("No recording in progress".to_string());
@@ -61,55 +54,183 @@ pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
state.active = false;
if state.frame_count == 0 {
let _ = std::fs::remove_dir_all(&state.temp_dir);
return Err("No frames captured".to_string());
}
let frame_pattern = state
.temp_dir
.join("frame_%06d.jpg")
.to_string_lossy()
.to_string();
Ok(json!({ "path": &state.output_path, "frames": state.frame_count }))
}
let output = &state.output_path;
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
};
// Encode with ffmpeg
let result = Command::new("ffmpeg")
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([
"-y",
"-framerate",
"30",
"-i",
&frame_pattern,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-preset",
"fast",
output,
"-fpsprobesize",
"0",
"-probesize",
"32",
"-analyzeduration",
"0",
])
.output();
.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"]);
let _ = std::fs::remove_dir_all(&state.temp_dir);
match result {
Ok(output_result) => {
if output_result.status.success() {
Ok(json!({ "path": output, "frames": state.frame_count }))
} else {
let stderr = String::from_utf8_lossy(&output_result.stderr);
Err(format!(
"ffmpeg failed: {}",
stderr.chars().take(200).collect::<String>()
))
}
}
Err(e) => Err(format!(
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
e
)),
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)]
@@ -132,19 +253,15 @@ mod tests {
assert!(state.active);
assert_eq!(state.output_path, "/tmp/test.mp4");
assert_eq!(state.frame_count, 0);
// Cleanup
let _ = std::fs::remove_dir_all(&state.temp_dir);
}
#[test]
fn test_recording_start_while_active() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
let temp_dir = state.temp_dir.clone();
let result = recording_start(&mut state, "/tmp/test2.mp4");
assert!(result.is_err());
assert!(result.unwrap_err().contains("already active"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
@@ -166,38 +283,41 @@ mod tests {
}
#[test]
fn test_recording_add_frame_inactive() {
fn test_recording_restart_while_inactive() {
let mut state = RecordingState::new();
recording_add_frame(&mut state, b"fake-frame");
assert_eq!(state.frame_count, 0);
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_add_frame_active() {
fn test_recording_restart_while_active() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test.mp4").unwrap();
recording_add_frame(&mut state, b"fake-frame-1");
recording_add_frame(&mut state, b"fake-frame-2");
assert_eq!(state.frame_count, 2);
let _ = std::fs::remove_dir_all(&state.temp_dir);
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"));
}
}
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,
}))
}
+588 -44
View File
@@ -1,16 +1,65 @@
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 {
@@ -21,16 +70,111 @@ impl Default for ScreenshotOptions {
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,
) -> Result<(String, String), String> {
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" {
@@ -64,40 +208,14 @@ pub async fn take_screenshot(
});
}
} else if let Some(ref selector) = options.selector {
// Element screenshot via bounding box
let object_id =
super::element::resolve_element_object_id(client, session_id, ref_map, selector)
.await?;
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),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if let Some(rect) = result.result.value {
let x = rect.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = rect.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let w = rect.get("width").and_then(|v| v.as_f64()).unwrap_or(100.0);
let h = rect.get("height").and_then(|v| v.as_f64()).unwrap_or(100.0);
if let Some(rect) =
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
{
params.clip = Some(Viewport {
x,
y,
width: w,
height: h,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
scale: 1.0,
});
}
@@ -107,16 +225,345 @@ pub async fn take_screenshot(
.send_command_typed("Page.captureScreenshot", &params, Some(session_id))
.await?;
let ext = if options.format == "jpeg" {
"jpg"
} else {
"png"
};
Ok(result.data)
}
let save_path = match &options.path {
Some(p) => p.clone(),
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 = get_screenshot_dir();
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)
@@ -127,13 +574,17 @@ pub async fn take_screenshot(
}
};
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.data)
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, result.data))
Ok(save_path)
}
fn round(value: f64) -> i64 {
value.round() as i64
}
fn get_screenshot_dir() -> PathBuf {
@@ -145,3 +596,96 @@ fn get_screenshot_dir() -> PathBuf {
.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);
}
}
+744 -143
View File
File diff suppressed because it is too large Load Diff
+323 -43
View File
@@ -1,12 +1,17 @@
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::EvaluateParams;
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
CreateTargetResult, EvaluateParams,
};
use super::cookies::{self, Cookie};
#[derive(Debug, Serialize, Deserialize)]
@@ -32,16 +37,223 @@ pub struct StorageEntry {
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_cookies(client, session_id, None).await?;
let cookies = cookies::get_all_cookies(client, session_id).await?;
// Get current origin's storage
let origin_js = r#"(() => {
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
try {
@@ -59,46 +271,38 @@ pub async fn save_state(
return result;
})()"#;
let origin_result: super::cdp::types::EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: origin_js.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let origin_data = origin_result.result.value.unwrap_or(Value::Null);
let origins = if origin_data.is_object() {
let origin = origin_data
.get("origin")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let local_storage: Vec<StorageEntry> = origin_data
.get("localStorage")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let session_storage: Vec<StorageEntry> = origin_data
.get("sessionStorage")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if !origin.is_empty() && origin != "null" {
vec![OriginStorage {
origin,
local_storage,
session_storage,
}]
} else {
vec![]
// 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);
}
} else {
vec![]
};
}
// 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)
@@ -467,7 +671,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
best_path = Some((path.to_string_lossy().to_string(), modified));
}
}
@@ -475,6 +679,41 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
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")
@@ -604,4 +843,45 @@ mod tests {
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");
}
}
+237
View File
@@ -0,0 +1,237 @@
//! Stealth anti-detection module.
//!
//! Injects browser-level patches to evade bot detection (creepjs, sannysoft,
//! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray
//! headless or automated Chrome instances.
use serde_json::json;
use super::cdp::client::CdpClient;
/// Full stealth JS payload compiled at build time (for --launch mode).
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome).
/// Only removes navigator.webdriver — the browser's own fingerprint is already real.
/// Minimal stealth script for CDP-attach mode.
/// Emulation.setAutomationOverride handles navigator.webdriver at the native
/// level, so no JS patching is needed in CdpAttach mode. An empty script
/// avoids creating any detectable lie-props artifacts.
const MINIMAL_STEALTH_SCRIPT: &str = "";
/// Chrome launch arguments that reduce automation fingerprint surface.
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
"--disable-blink-features=AutomationControlled",
"--use-gl=angle",
"--use-angle=default",
];
/// Connection mode determines which stealth patches to apply.
#[derive(Clone, Copy, PartialEq)]
pub enum StealthMode {
/// Connected to user's real Chrome — minimal patches only (webdriver removal).
/// The browser already has a real fingerprint; heavy patches would create detectable lies.
CdpAttach,
/// Launched a new Chrome instance — apply full stealth patches.
FullLaunch,
}
/// Build the stealth JS payload for the given mode and locale.
pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
if mode == StealthMode::CdpAttach {
return MINIMAL_STEALTH_SCRIPT.to_string();
}
// Full launch mode: inject all patches
let locale = locale.unwrap_or("en-US");
let base_lang = locale.split('-').next().unwrap_or(locale);
let languages: Vec<&str> = if base_lang == locale {
vec![locale]
} else {
vec![locale, base_lang]
};
let config_line = format!(
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
locale,
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
);
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
) {
format!("{}{}", config_line, rest)
} else {
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
}
}
/// Apply stealth patches to a browser session.
///
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
pub async fn apply_stealth(
client: &CdpClient,
session_id: &str,
mode: StealthMode,
locale: Option<&str>,
) -> Result<(), String> {
// First: disable the automation flag at the CDP protocol level.
// This tells Chrome to natively set navigator.webdriver = false,
// which is undetectable by lie-detection systems like CreepJS.
// Falls back gracefully on older Chrome versions that don't support this.
let _ = client
.send_command(
"Emulation.setAutomationOverride",
Some(json!({ "enabled": false })),
Some(session_id),
)
.await;
let script = build_stealth_script(mode, locale);
// Inject stealth scripts to run before page JS
client
.send_command(
"Page.addScriptToEvaluateOnNewDocument",
Some(json!({ "source": script })),
Some(session_id),
)
.await?;
// In full launch mode, also override UA to remove HeadlessChrome marker
if mode == StealthMode::FullLaunch {
let ua = get_browser_user_agent(client, session_id).await;
if let Some(ua) = ua {
let cleaned = ua.replace("HeadlessChrome", "Chrome");
if cleaned != ua {
client
.send_command(
"Emulation.setUserAgentOverride",
Some(json!({
"userAgent": cleaned,
"acceptLanguage": locale.unwrap_or("en-US"),
"platform": platform_string(),
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
})),
Some(session_id),
)
.await?;
}
}
}
Ok(())
}
/// Get the browser's User-Agent string via CDP.
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
let result = client
.send_command(
"Runtime.evaluate",
Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })),
Some(session_id),
)
.await
.ok()?;
result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.map(String::from)
}
/// Also run stealth script on the current page (for already-loaded pages after CDP attach).
pub async fn apply_stealth_to_current_page(
client: &CdpClient,
session_id: &str,
mode: StealthMode,
locale: Option<&str>,
) -> Result<(), String> {
let script = build_stealth_script(mode, locale);
client
.send_command(
"Runtime.evaluate",
Some(json!({
"expression": script,
"returnByValue": true,
})),
Some(session_id),
)
.await?;
Ok(())
}
/// Strip sourceURL comments from CDP expressions to avoid leaking
/// automation-framework identifiers in stack traces.
pub fn strip_source_url_labels(input: &str) -> String {
// Remove //# sourceURL=... and //@ sourceURL=...
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
let output = re_line.replace_all(input, "");
// Remove /*# sourceURL=...*/ block comments
let re_block =
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
re_block.replace_all(&output, "").to_string()
}
fn platform_string() -> &'static str {
if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "windows") {
"Win32"
} else {
"Linux"
}
}
fn platform_hint() -> &'static str {
if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "windows") {
"Windows"
} else {
"Linux"
}
}
fn platform_version_hint() -> &'static str {
if cfg!(target_os = "macos") {
"14.0.0"
} else if cfg!(target_os = "windows") {
"10.0.0"
} else {
"6.5.0"
}
}
fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
// Extract Chrome version from UA string
let chrome_version = ua
.split("Chrome/")
.nth(1)
.and_then(|s| s.split_whitespace().next())
.unwrap_or("130.0.0.0");
let major = chrome_version.split('.').next().unwrap_or("130");
let _lang = locale.unwrap_or("en-US");
json!({
"brands": [
{ "brand": "Chromium", "version": major },
{ "brand": "Google Chrome", "version": major },
{ "brand": "Not?A_Brand", "version": "99" },
],
"fullVersionList": [
{ "brand": "Chromium", "version": chrome_version },
{ "brand": "Google Chrome", "version": chrome_version },
{ "brand": "Not?A_Brand", "version": "99.0.0.0" },
],
"fullVersion": chrome_version,
"platform": platform_hint(),
"platformVersion": platform_version_hint(),
"architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" },
"model": "",
"mobile": false,
"bitness": "64",
"wow64": false,
})
}
File diff suppressed because it is too large Load Diff
+1434 -34
View File
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>
+60 -21
View File
@@ -40,32 +40,45 @@ impl AppiumManager {
})
}
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 mut caps = json!({
"platformName": "iOS",
"automationName": "XCUITest",
"browserName": "Safari",
"noReset": true,
});
if let Some(name) = device_name {
caps["deviceName"] = json!(name);
} else {
caps["deviceName"] = json!("iPhone");
}
if let Some(ver) = platform_version {
caps["platformVersion"] = json!(ver);
}
if let Some(ref udid) = self.device_udid {
caps["udid"] = json!(udid);
}
let caps = Self::build_ios_capabilities(
self.device_udid.as_deref(),
device_name,
platform_version,
);
self.client.create_session(caps).await
}
@@ -198,4 +211,30 @@ mod tests {
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());
}
}
+26 -26
View File
@@ -212,32 +212,6 @@ impl WebDriverClient {
}
}
#[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");
}
}
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");
@@ -316,3 +290,29 @@ async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<V
)
})
}
#[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");
}
}
+498 -257
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);
}
}
-41
View File
@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-272
View File
@@ -1,272 +0,0 @@
# PRD: CLI Web 数据采集体验优化(以小红书场景为例)
- 文档版本: v0.1
- 状态: Draft
- 作者: Codex
- 日期: 2026-03-04
## 1. 背景与问题
在使用 `agent-browser` CLI 执行「小红书宠物博主采集(100 条)」时,当前流程可完成任务,但存在明显的可用性与稳定性痛点:
1. 网络层可观测性不足,响应体抓取不稳定,需注入脚本劫持。
2. 分页采集依赖手工 `scroll down + wait`,重复劳动且易漏数据。
3. 结构化导出缺少一站式命令,需要 `eval` 二次解析。
4. 页面交互依赖文本选择,页面文案变动后脆弱。
5. 反爬失败时缺少可解释的自动回退策略。
6. 用户对“可抓字段”预期不清(例如搜索接口无联系方式)。
7. 长会话缺少快照与断点续抓机制。
## 2. 目标与非目标
## 2.1 目标
1. 将常见采集链路从“脚本拼接”降为“CLI 原生命令组合”。
2. 让关键动作具备可观测性(日志)和可恢复性(快照/续跑)。
3. 降低站点轻微改版、反爬限制带来的失败率。
## 2.2 非目标
1. 不承诺绕过平台强风控或登录体系。
2. 不在本期实现完整通用爬虫 DSL。
3. 不默认抓取平台未公开展示的隐私字段。
## 3. 目标用户与核心场景
1. 增长/运营: 按关键词采集账号基础数据并导出 CSV。
2. 测试/研发: 复现抓取问题,定位请求失败原因。
3. AI Agent 工作流: 在 CLI 内稳定执行“搜索 -> 翻页 -> 提取 -> 导出”。
## 4. 需求范围与优先级
## 4.1 P0
1. `network capture` 增强模式(可过滤、可落盘 response body)。
2. `scroll-collect` 自动滚动采集(按页数或直到无新增)。
3. `extract` / `extract-to` 结构化导出(JSON/CSV)。
## 4.2 P1
1. 语义选择器与 fallback 链(role/aria/data/text)。
2. 401/403/406 智能回退(页面触发 + 回包监听)。
3. 可抓字段矩阵与二段式采集文档提示。
## 4.3 P2
1. `session snapshot` + `crawl resume` 断点续抓。
## 5. CLI 方案设计
## 5.1 网络捕获增强
命令草案:
```bash
agent-browser network capture --match '/api/sns/web/v1/search/usersearch' --save ./out.ndjson
agent-browser network capture --domain edith.xiaohongshu.com --method POST --save ./xhs_usersearch.ndjson
```
参数:
- `--match <regex>`: 按 URL 正则过滤。
- `--domain <host>`: 按域名过滤。
- `--method <GET|POST|...>`: 按方法过滤。
- `--status <code|range>`: 按状态过滤。
- `--save <path>`: NDJSON 输出文件。
- `--include-body <request|response|both>`: 控制 body 输出范围。
- `--max-body-bytes <n>`: 单条 body 截断阈值。
NDJSON 记录结构:
```json
{
"ts": "2026-03-04T10:00:00.123Z",
"session_id": "sess_abc",
"request_id": "req_123",
"method": "POST",
"url": "https://edith.xiaohongshu.com/api/sns/web/v1/search/usersearch",
"status": 200,
"duration_ms": 312,
"request_headers": {"content-type": "application/json"},
"request_body": "{...}",
"response_headers": {"content-type": "application/json"},
"response_body": "{...}",
"truncated": false
}
```
## 5.2 自动滚动采集
命令草案:
```bash
agent-browser scroll-collect --until no-new-items --max-steps 200 --idle-rounds 3
agent-browser scroll-collect --pages 20 --wait-ms 1200
```
行为:
1. 每轮执行滚动与等待。
2. 基于 DOM 项数量或网络新增请求判断“是否有新增”。
3. 达到停止条件后输出结束原因。
输出示例:
```text
step=1 new_items=15 total_items=15
step=2 new_items=15 total_items=30
...
stop_reason=no-new-items idle_rounds=3 total_items=135
```
## 5.3 结构化提取与导出
命令草案:
```bash
agent-browser extract --from network --match usersearch --fields 'name,fans,note_count,red_id'
agent-browser extract-to --from network --match usersearch --fields 'name,fans,note_count,red_id,url' --format csv --out ./users.csv
```
参数:
- `--from <network|dom|eval>`: 数据源。
- `--match <pattern>`: 来源过滤(URL/事件名)。
- `--query <JMESPath|JSONPath>`: 自定义提取表达式。
- `--fields <a,b,c>`: 字段映射快捷写法。
- `--dedupe-by <field>`: 去重键。
- `--limit <n>`: 限制条数。
- `--format <json|ndjson|csv>`: 输出格式。
- `--out <path>`: 文件输出路径。
## 5.4 语义选择器与回退链
命令草案:
```bash
agent-browser click --selector 'role=tab[name="用户"]' --fallback 'aria=用户,text=用户'
agent-browser find --selector 'data-testid=user-tab' --fallback 'role=tab[name="用户"],text=用户'
```
策略:
1. 主选择器失败后按 fallback 顺序重试。
2. 日志打印每次尝试与失败原因。
## 5.5 反爬失败自动回退
命令草案:
```bash
agent-browser request replay --on-status 401,403,406 --fallback page-action
```
策略:
1. 直接请求失败后自动回退到页面行为触发。
2. 自动复用 UA/Referer/Cookie Jar。
3. 捕获最终有效响应并给出“回退成功/失败”日志。
## 5.6 会话快照与断点续抓
命令草案:
```bash
agent-browser session snapshot save ./snapshots/xhs-20260304.json
agent-browser crawl resume --snapshot ./snapshots/xhs-20260304.json --out ./users.csv
```
快照最小字段:
- 当前 URL
- 关键词/筛选参数
- 已抓 user_id 集合摘要(可哈希分片)
- 分页进度(page/scroll step
- 导出配置(fields/format/out
## 6. 错误码设计(草案)
- `AB_NET_CAPTURE_BODY_UNAVAILABLE` (1001): 响应体不可用(被浏览器策略阻断或已释放)。
- `AB_SCROLL_TIMEOUT_NO_PROGRESS` (1101): 滚动超时且无新增。
- `AB_EXTRACT_QUERY_INVALID` (1201): 提取表达式语法错误。
- `AB_EXTRACT_OUTPUT_FAILED` (1202): 导出失败(权限/路径不可写)。
- `AB_SELECTOR_NOT_FOUND` (1301): 主选择器与 fallback 全部失败。
- `AB_REQUEST_BLOCKED_406` (1406): 请求被风控拦截,且回退链路失败。
- `AB_RESUME_SNAPSHOT_INVALID` (1501): 快照损坏或版本不兼容。
要求:
1. CLI 退出码与错误码可映射。
2. 错误输出提供 `hint`(下一步建议命令)。
## 7. 日志与可观测性
默认人类可读,开启 `--log-format json` 输出结构化日志。
JSON 日志字段:
- `ts`
- `level`
- `session_id`
- `command`
- `event`
- `step`
- `url`
- `status`
- `error_code`
- `message`
- `hint`
示例:
```json
{"ts":"2026-03-04T10:11:22.123Z","level":"INFO","command":"scroll-collect","event":"step","step":12,"new_items":15,"total_items":180}
{"ts":"2026-03-04T10:13:01.001Z","level":"WARN","command":"request replay","event":"fallback","status":406,"message":"direct request blocked, fallback to page-action"}
```
## 8. 文档与帮助信息更新要求
当功能落地时,需要同步更新以下位置(按仓库规范):
1. `cli/src/output.rs``--help`、示例、环境变量)
2. `README.md`(命令选项、样例)
3. `skills/agent-browser/SKILL.md`Agent 工作流)
4. `docs/src/app/`(新增/更新 MDX 页面,表格使用 HTML `<table>`
5. 对应源码内联注释
## 9. 验收用例(首批)
1. `network capture` 能稳定保存目标接口完整 request/response body。
2. 设置 `--max-body-bytes` 后被截断记录带 `truncated=true`
3. `scroll-collect --pages 5` 精确执行 5 轮并退出。
4. `scroll-collect --until no-new-items` 在连续空增量 N 轮后退出。
5. `extract-to ... --format csv` 产出可打开 CSV 且列名正确。
6. `extract --dedupe-by user_id` 去重结果稳定。
7. selector 主规则失败时,fallback 生效并成功点击。
8. 对 406 场景触发自动回退并成功捕获有效响应。
9. 回退失败时返回 `AB_REQUEST_BLOCKED_406` 且提供 hint。
10. `session snapshot save/load` 前后任务可恢复。
11. `crawl resume` 不重复导出已抓 ID。
12. `--log-format json` 日志字段完整,便于机器消费。
## 10. 里程碑建议
1. M11 周): `network capture` + `scroll-collect`
2. M21 周): `extract-to` + selector fallback。
3. M31 周): 406 回退链路 + 文档补全。
4. M41 周): snapshot/resume + 稳定性打磨。
## 11. 风险与缓解
1. 平台策略变化导致规则失效。
缓解: 增加站点适配层与策略开关,保留回退日志。
2. 响应体过大带来内存与 IO 压力。
缓解: 流式写入 NDJSON + 截断阈值。
3. 通用提取表达式学习成本高。
缓解: 提供字段模板与场景 presets。
## 12. 开放问题
1. `extract` 表达式标准优先 JSONPath 还是 JMESPath
2. `session snapshot` 是否需要加密(含 cookie 元信息)?
3. 是否提供站点模板(如 `preset xiaohongshu-user-search`)以降低上手成本?
@@ -1,192 +0,0 @@
# 浏览器自动化攻防方案设计:检测模型与分层控制面
本文聚焦浏览器自动化的攻防方案设计,按两个部分组织:
1. **原理**:风险评分系统如何形成结论
2. **控制面**:如何用分层设计降低风险与波动
本文不包含命令行操作与工程实现步骤。
Turnstile 专题内容见:
[Cloudflare Turnstile 攻防方案设计:系统原理与控制面](https://blog.misonote.com/zh/posts/cloudflare-turnstile-stability-principles/)
---
## 一、原理
### 1.1 风险评分不是单点命中
高风控站点的“是否挑战/是否降权”通常来自多维评分,而不是某一条规则的二元判断。
主要输入维度:
1. **一致性**:同一身份在不同表面是否互相矛盾
2. **稀有性**:低频异常组合是否出现
3. **时序性**:行为时间序列是否呈机械统计特征
4. **执行完整性**:关键链路(挑战脚本、跨域资源、worker)是否被破坏
```mermaid
flowchart LR
A["环境与行为"] --> B["一致性评分"]
A --> C["稀有性评分"]
A --> D["时序评分"]
A --> E["执行完整性评分"]
B --> F["综合风险"]
C --> F
D --> F
E --> F
F --> G{"放行/挑战/限流"}
```
### 1.2 一致性:约束集合而非单点修饰
一致性问题的本质是“同一身份在多个观测面上的约束必须同时成立”。
#### 1.2.1 约束集合示意
可以把身份一致性建模为“约束图”:
```mermaid
flowchart TD
UA["UA 字符串"] --> UACH["UA-CH / userAgentMetadata"]
UA --> LangH["Accept-Language"]
LangH --> LangJS["navigator.language(s)"]
LangJS --> Intl["Intl locale/timeZone"]
Plat["platform"] --> Rend["渲染能力/WebGL"]
Rend --> Win["窗口/屏幕参数"]
UACH --> Plat
```
图中每条边表示“两个表面必须相互一致”,否则会形成冲突分值。
#### 1.2.2 典型冲突类型
- UA 显示平台/版本与 UA-CH 不一致
- `Accept-Language``navigator.languages` 不一致
- `Intl` 时区与偏移/地区推断不一致
- 设备声明与渲染能力组合异常
工程含义:
- 修一个点可能打破另一个点
- 设计顺序应是“先定约束集合,再决定每个表面如何满足约束”
### 1.3 稀有性:组合风险而非单值风险
稀有性来自“低频组合”,其危险性来自共现而非单项。
可以将稀有性理解为“联合分布”偏离:
- 单项偏离:可被容忍
- 多项共现偏离:风险迅速累积
工程含义:
- 目标是减少低频组合在同一会话内叠加
- 目标不是拟合某个固定画像
### 1.4 时序性:统计特征而非行为语义
行为检测通常关注统计分布特征:
- 低方差:动作间隔过于稳定
- 强周期:间隔呈固定节奏
- 强同步:不同类型动作间隔一致
工程含义:
- 行为治理的目标是“分布塑形”(variance/jitter/backoff
- 行为治理不是“添加更多动作”
### 1.5 执行完整性:上游条件
执行完整性属于“系统是否能正确运行”的前置条件。
- challenge 脚本、跨域 iframe、跨域 worker 的语义被破坏时,失败率会显著上升
- 此类失败可能与“是否被识别”为不同类别的问题
工程原则:
> 执行链路保护优先于信号修饰。
---
## 二、控制面(分层设计)
### 2.1 控制面总览
攻防方案可以拆为四层控制面:
1. **启动控制**:治理启动早期显式风险
2. **协议控制**:治理协议层身份一致性
3. **运行时控制**:治理页面脚本可观测表面
4. **行为与会话控制**:治理时序分布与上下文漂移
```mermaid
flowchart LR
A["启动控制"] --> B["协议控制"]
B --> C["运行时控制"]
C --> D["行为与会话控制"]
D --> E["一致性与稳定性"]
```
### 2.2 启动控制
目标:降低会话早期显式风险。
设计约束:
- 只处理高置信度自动化标识
- 避免引入与协议层/运行时层不一致的改动
### 2.3 协议控制
目标:将身份约束集合落实到协议层输出。
设计要点:
- 将 UA 与 UA-CH 视为同一约束集合的不同投影
- 覆盖范围需要与目标(页面/worker/子目标)一致
### 2.4 运行时控制
目标:覆盖高频探测面,同时保证不破坏执行语义。
设计要点:
- 优先治理高频、可解释的探测路径
- 对跨域挑战链路对象设置严格注入边界
### 2.5 行为与会话控制
目标:塑形时间分布,减少上下文漂移。
设计要点:
- 行为治理以统计分布为目标(variance/jitter/backoff
- 会话治理以一致上下文为目标(避免身份漂移)
### 2.6 挑战场景控制面摘要(Turnstile)
Turnstile 场景下的关键控制面可抽象为:
1. 能力令牌语义:服务端验证、有限时效、单次消费
2. 作用域收缩:`hostname/action/cdata` 收缩滥用空间
3. 执行链路保护:跨域脚本/iframe/worker 语义保护
4. 摩擦与安全分离:clearance 属于体验层,不替代安全决策层
该摘要用于将 Turnstile 纳入统一控制面框架;细节见专题文章。
---
## 三、方案设计优先级
控制面设计通常按以下优先级推进:
1. 执行完整性(保证链路可运行)
2. 一致性约束集合(消除跨表面矛盾)
3. 稀有性控制(避免低频组合叠加)
4. 时序分布塑形(降低机械统计特征)
5. 体验优化(降低重复挑战摩擦)
该顺序的含义是先保证“系统正确性”,再优化“稳定性与摩擦”。
@@ -1,250 +0,0 @@
# Cloudflare Turnstile 攻防方案设计:系统原理与控制面
本文聚焦 Turnstile 的攻防方案设计:
1. **系统原理**:token 的安全语义、挑战执行链路、风险评分的输入输出
2. **控制面设计**:在不同攻击面下,哪些约束是必要的、哪些约束容易引入副作用
本文不包含命令行操作与工程实现步骤。
---
## 一、系统原理
### 1.1 Turnstile 是“能力令牌”系统
Turnstile 的本质是签发一个短生命周期、单次消费的能力令牌(capability token)。
- **签发端**:浏览器端完成挑战执行后获得 token
- **消费端**:业务服务端通过 Siteverify 验证 token 并决定是否放行
```mermaid
flowchart LR
A["浏览器端挑战执行"] --> B["token"]
B --> C["业务服务端"]
C --> D["Siteverify"]
D --> E{"放行/拒绝"}
```
关键含义:
- 前端任何“通过”状态都不是业务放行条件
- 业务放行条件是“token 被正确消费”
### 1.2 Token 的三条安全语义
token 的安全语义可以抽象为三条约束:
1. **必须服务端验证**:不允许仅以前端回调作为依据
2. **有限时效**token 超过时效窗口即失效
3. **单次消费**:同一 token 重复消费应失败
这三条语义分别封装了三个常见攻击目标:
- 伪通过:绕过服务端验证
- 延迟提交:绕过时效窗口
- 重放/并发:绕过单次消费
### 1.3 挑战执行链路是“跨域执行系统”
Turnstile 的 token 产生依赖多组件协作,且跨域链路占主导:
- `api.js` 脚本
- challenge iframe
- challenge worker
- 跨域资源请求
```mermaid
flowchart TD
A["加载 api.js"] --> B["创建 iframe"]
B --> C["执行 worker"]
C --> D["收集信号 + 风险评估"]
D --> E["签发 token"]
```
该链路的工程含义:
- 任何对跨域脚本/iframe/worker 的语义改写,都可能导致 token 生成失败或质量下降
- token 失败不一定意味着“被识别”,也可能是“链路被破坏”
### 1.4 风险评分:输入不是“真假”,而是“自洽程度”
挑战执行阶段会收集环境与行为信号,形成风险评分。
- **信号输入**:环境一致性(UA/UA-CH、语言/时区、渲染能力、能力暴露)
- **行为输入**:时序分布(方差、周期性、同步性)
风险评分的关键不是“拟合某种固定画像”,而是“同一身份在多表面是否自洽”。
### 1.5 作用域绑定:hostname / action / cdata
服务端校验时提供用于绑定业务语义的字段:
- `hostname`token 允许的站点作用域
- `action`token 允许的动作作用域
- `cdata`token 允许的上下文作用域
这些字段的作用是“收缩 token 可被滥用的范围”,而不是“提高通过率”。
```mermaid
flowchart LR
A["token"] --> B["hostname 作用域"]
A --> C["action 作用域"]
A --> D["cdata 作用域"]
B --> E["降低站外盗用收益"]
C --> F["降低动作错配收益"]
D --> G["降低跨流程重放收益"]
```
### 1.6 Token 状态机(能力令牌视角)
从能力令牌视角,token 生命周期可抽象为:
```mermaid
stateDiagram-v2
[*] --> Issued: challenge ok
Issued --> Consumed: siteverify ok
Issued --> Expired: time window
Issued --> Rejected: binding mismatch
Issued --> Replayed: reused
Replayed --> Rejected
Expired --> Rejected
Consumed --> [*]
```
设计目标是让“非法路径”快速失败,并且失败类型可被服务端语义区分。
### 1.7 攻击树(高层)
Turnstile 的主要攻击目标可以抽象为:
```mermaid
flowchart TD
A["绕过业务动作门禁"] --> B["伪造或跳过服务端验证"]
A --> C["重放 token"]
A --> D["扩大 token 作用域"]
A --> E["破坏挑战执行以制造降级路径"]
C --> C1["并发提交"]
C --> C2["延迟提交"]
D --> D1["Any Hostname"]
D --> D2["action/cdata 缺失"]
```
该攻击树强调设计重点:
- 安全决策必须在服务端闭环
- token 必须被作用域收缩并按语义消费
---
## 二、控制面设计(攻防视角)
### 2.1 控制面分层
Turnstile 防线可以分为四层控制面:
1. **挑战执行控制**:保证脚本/iframe/worker 跨域链路完整
2. **服务端消费控制**:保证 token 的语义被正确消费
3. **作用域控制**:收缩 `hostname/action/cdata` 的可用范围
4. **摩擦控制**:clearance 用于降低挑战摩擦(不作为安全决策依据)
```mermaid
flowchart LR
A["挑战执行控制"] --> E["token 可生成"]
A --> F["token 质量"]
B["服务端消费控制"] --> G["安全决策闭环"]
C["作用域控制"] --> H["滥用收益收缩"]
D["摩擦控制"] --> I["挑战频率下降"]
```
### 2.2 挑战执行控制:跨域语义保护优先
挑战执行链路对跨域执行语义高度敏感。
原则:
- 跨域脚本/iframe/worker 避免语义改写
- 所有指纹修饰必须先满足“不破坏挑战执行”这一硬约束
该原则的工程含义:
- “执行完整性”是上游条件
- “信号修饰”是下游优化
### 2.3 服务端消费控制:把 token 当作能力消费
服务端消费控制的设计关键在于“放行条件定义”,而不是“接口调用细节”。
放行条件应体现三类约束:
- 真实性:校验 `success`
- 作用域:校验 `hostname`
- 语义绑定:校验 `action/cdata`
并且必须贯彻 token 的两个安全语义:
- 时效性:过期拒绝
- 单次性:重放拒绝
从攻防角度,该层解决的是“绕过与重放”。
### 2.4 作用域控制:Hostname Management 与 Any Hostname
Hostname 管理解决“站外盗用”的攻击面。
- 启用 Hostname Management:收缩 token 可用站点范围
- 启用 Any Hostname:扩大 token 可用站点范围
设计结论:
- Any Hostname 不是“更灵活”,而是“扩大攻击面”,必须用更强的服务端约束做补偿控制(来源域白名单 + 业务绑定)。
### 2.5 摩擦控制:Pre-clearance 与 cf_clearance 的边界
Pre-clearance 通过后可产生 clearance,用于后续 WAF 挑战联动。
边界定义:
- clearance 用于体验层(降低重复挑战摩擦)
- Siteverify 用于安全决策层(业务放行依据)
将两者混用会引入“体验信号替代安全信号”的设计缺陷。
### 2.6 高对抗场景:代理池与设备关联
在代理池与分布式滥用场景中,单一 IP 维度约束容易失效。
设计方向是引入更稳定的关联维度(例如设备级 ephemeral id),用于聚类与阈值策略。
该层属于平台能力与业务风控的交界:
- 平台提供关联信号
- 业务定义动作分层、阈值与处置策略
---
## 三、方案设计优先级
Turnstile 攻防设计通常按以下优先级推进:
1. 服务端消费语义闭环(真实性 + 作用域 + 绑定 + 单次性 + 时效性)
2. 挑战执行链路完整性(跨域语义保护)
3. 信号一致性(减少跨字段矛盾)
4. 行为时序(降低机械分布)
5. 体验优化(clearance 等摩擦控制)
该顺序的含义是先定义“正确的安全决策”,再优化“挑战摩擦与通过率波动”。
---
## 官方参考(概念与配置)
- Widgets: <https://developers.cloudflare.com/turnstile/concepts/widget/>
- Widget configurations: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/>
- Server-side validation: <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/>
- CSP: <https://developers.cloudflare.com/turnstile/reference/content-security-policy/>
- Hostname management: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/>
- Any Hostname: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/any-hostname/>
- Pre-clearance: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/pre-clearance/>
- Cloudflare clearance: <https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/>
- Ephemeral IDs: <https://developers.cloudflare.com/turnstile/additional-configuration/ephemeral-id/>
@@ -1,97 +0,0 @@
# agent-browser 与 agent-browser-stealth:能力差异与选型
本文给出 `agent-browser``agent-browser-stealth` 的技术差异、适用场景和升级验证步骤。
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
---
## 1. 定位差异
- `agent-browser`:标准浏览器自动化能力
- `agent-browser-stealth`:在标准自动化能力基础上,增加反检测与高风控场景稳定性能力
---
## 2. 核心能力对比
| 维度 | agent-browser | agent-browser-stealth |
| --- | --- | --- |
| 自动化基础能力 | 支持 | 支持 |
| 指纹一致性治理 | 基础 | 多层(launch/CDP/init-script |
| 高风控站点稳定性 | 一般 | 更高 |
| 会话连续性(附着现有浏览器) | 支持 | 支持,默认附着策略更明确 |
| Cloudflare/Turnstile 回归工具 | 无专用脚本 | `check:turnstile-testkey` |
---
## 3. Cloudflare/Turnstile 相关能力(v0.15.2-fork.2+
### 3.1 挑战链路保护
- 同源 worker 注入保留
- 跨域 challenge worker 不做注入改写
- 降低 challenge worker 执行异常概率
### 3.2 导航等待策略
`open/navigate` 支持:
- `--wait-until load`
- `--wait-until domcontentloaded`
- `--wait-until networkidle`
挑战页建议优先 `domcontentloaded`,减少 `load` 阶段超时误判。
### 3.3 确定性回归
提供官方 test key 回归脚本:
```bash
pnpm run check:turnstile-testkey
```
通过特征:输出 `XXXX.DUMMY.TOKEN.XXXX`
---
## 4. 适用场景
优先使用 `agent-browser-stealth` 的场景:
1. 目标站点存在挑战页/验证码/限流
2. 自动化链路对稳定性要求高
3. 需要长期回归验证与版本门禁
使用 `agent-browser` 的场景:
1. 低风控站点
2. 以基础自动化能力验证为主
---
## 5. 升级验证步骤
```bash
# 1) 检查版本
agent-browser -V
# 2) 关闭旧 daemon,避免版本漂移
agent-browser --session default close
# 3) 运行确定性回归
pnpm run check:turnstile-testkey
# 4) 可选:真实站点回归
agent-browser --wait-until domcontentloaded open https://www.anyviewer.com/cloudflare.html
```
如果启用域名白名单(`AGENT_BROWSER_ALLOWED_DOMAINS`),需包含 `challenges.cloudflare.com`
---
## 6. 结论
`agent-browser-stealth` 适用于高风控与稳定性敏感场景;`agent-browser` 适用于标准自动化场景。
选型建议按目标站点风控强度与回归要求决定。
-22
View File
@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-83
View File
@@ -1,83 +0,0 @@
import type { MDXComponents } from "mdx/types";
import Link from "next/link";
import { CodeBlock } from "@/components/code-block";
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.trim();
}
function extractText(children: React.ReactNode): string {
if (typeof children === "string") return children;
if (typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractText).join("");
if (children && typeof children === "object") {
const obj = children as unknown as Record<string, unknown>;
if ("props" in obj) {
const props = obj.props as { children?: React.ReactNode } | undefined;
return extractText(props?.children);
}
}
return "";
}
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
h2: ({ children }: { children?: React.ReactNode }) => {
const id = slugify(extractText(children));
return <h2 id={id}>{children}</h2>;
},
h3: ({ children }: { children?: React.ReactNode }) => {
const id = slugify(extractText(children));
return <h3 id={id}>{children}</h3>;
},
a: ({
href,
children,
}: {
href?: string;
children?: React.ReactNode;
}) => {
if (href?.startsWith("/")) {
return <Link href={href}>{children}</Link>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
code: ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
if (className) {
return <code className={className}>{children}</code>;
}
return <code>{children}</code>;
},
pre: async ({ children }: { children?: React.ReactNode }) => {
const codeElement = children as React.ReactElement<{
className?: string;
children?: string;
}>;
const className = codeElement?.props?.className || "";
const lang = className.replace("language-", "") || "bash";
const code = codeElement?.props?.children || "";
return (
<CodeBlock
code={typeof code === "string" ? code : String(code)}
lang={lang}
/>
);
},
};
}
-11
View File
@@ -1,11 +0,0 @@
import createMDX from "@next/mdx";
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
serverExternalPackages: ["just-bash", "bash-tool"],
};
const withMDX = createMDX({});
export default withMDX(nextConfig);
-48
View File
@@ -1,48 +0,0 @@
{
"name": "docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "portless agent-browser next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ai-sdk/react": "^3.0.80",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.1.6",
"@streamdown/code": "^1.0.2",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.78",
"bash-tool": "^1.3.14",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"just-bash": "^2.9.6",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"shiki": "^3.21.0",
"streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/mdx": "^2.0.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}
-8139
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.
Binary file not shown.
-119
View File
@@ -1,119 +0,0 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import type { ModelMessage, UIMessage } from "ai";
import { createBashTool } from "bash-tool";
import { headers } from "next/headers";
import { allDocsPages } from "@/lib/docs-navigation";
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
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.
GitHub repository: https://github.com/leeguooooo/agent-browser
Documentation: https://agent-browser.dev
npm package: agent-browser-stealth
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.
When answering questions:
- Use the bash tool to list files (ls /workspace/) or search for content (grep -r "keyword" /workspace/)
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/index.md")
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) you are read-only
- Always base your answers on the actual documentation content
- Be concise and accurate
- If the docs don't cover a topic, say so honestly
- Do NOT include source references or file paths in your response
- Do NOT use emojis in your responses`;
async function loadDocsFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};
const results = await Promise.allSettled(
allDocsPages.map(async (page) => {
const slug = page.href === "/" ? "" : page.href.replace(/^\//, "");
const filePath = slug
? join(process.cwd(), "src", "app", slug, "page.mdx")
: join(process.cwd(), "src", "app", "page.mdx");
const raw = await readFile(filePath, "utf-8");
const md = mdxToCleanMarkdown(raw);
const fileName = slug ? `/${slug}.md` : "/index.md";
return { fileName, md };
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
files[result.value.fileName] = result.value.md;
}
}
return files;
}
function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return messages;
return messages.map((message, index) => {
if (index === messages.length - 1) {
return {
...message,
providerOptions: {
...message.providerOptions,
anthropic: { cacheControl: { type: "ephemeral" } },
},
};
}
return message;
});
}
export async function POST(req: Request) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
]);
if (!minuteResult.success || !dailyResult.success) {
const isMinuteLimit = !minuteResult.success;
return new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: isMinuteLimit
? "Too many requests. Please wait a moment before trying again."
: "Daily limit reached. Please try again tomorrow.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { messages }: { messages: UIMessage[] } = await req.json();
const docsFiles = await loadDocsFiles();
const {
tools: { bash, readFile },
} = await createBashTool({ files: docsFiles });
const result = streamText({
model: DEFAULT_MODEL,
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(5),
tools: { bash, readFile },
prepareStep: ({ messages: stepMessages }) => ({
messages: addCacheControl(stepMessages),
}),
});
return result.toUIMessageStreamResponse();
}
-40
View File
@@ -1,40 +0,0 @@
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 });
}
}
-267
View File
@@ -1,267 +0,0 @@
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 auto-attaches to an existing browser by trying `localhost:9333` first, then auto-discovery. If both fail, the command exits (no managed 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
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot
```
## Remote WebSocket URLs
Connect to remote browser services via WebSocket URL:
```bash
# Connect to remote browser service
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot
# Works with any CDP-compatible service
agent-browser --cdp "ws://localhost:9222/devtools/browser/abc123" open example.com
```
The `--cdp` flag accepts either:
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
## Auto-Connect
Use `--auto-connect` to automatically discover and connect to a running Chrome instance without specifying a port:
```bash
# Auto-discover running Chrome with remote debugging
agent-browser --auto-connect open example.com
agent-browser --auto-connect snapshot
# Or via environment variable
AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
```
Auto-connect discovers Chrome by:
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
2. Falling back to probing common debugging ports (9222, 9229, 9333)
This is useful when:
- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port)
- You want a zero-configuration connection to your existing browser
- You don't want to track which port Chrome is using
## 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:
```bash
agent-browser --cdp 9222 --color-scheme dark open https://example.com
agent-browser --cdp 9222 snapshot # stays in dark mode
```
Or set it globally via config or environment variable:
```bash
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:
- Electron apps
- Chrome/Chromium with remote debugging
- WebView2 applications
- Remote browser services (via WebSocket URL)
- Any browser exposing a CDP endpoint
## Global options
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</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>--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>
<tr>
<td>
<code>--user-agent &lt;ua&gt;</code>
</td>
<td>Custom User-Agent string</td>
</tr>
<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>
<tr>
<td>
<code>{'--cdp <port|url>'}</code>
</td>
<td>CDP connection (port or WebSocket URL)</td>
</tr>
<tr>
<td>
<code>--auto-connect</code>
</td>
<td>Auto-discover and connect to running Chrome</td>
</tr>
<tr>
<td>
<code>--color-scheme &lt;scheme&gt;</code>
</td>
<td>
Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
</td>
</tr>
<tr>
<td>
<code>--debug</code>
</td>
<td>Debug output</td>
</tr>
</tbody>
</table>
## Cloud providers
Use cloud browser infrastructure when local browsers aren't available:
```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`.
-572
View File
@@ -1,572 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("changelog")
# Changelog
## v0.16.0
<p className="text-[#888] text-sm">March 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 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 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 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 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 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 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>
### New Features
- **Session persistence** - Automatic save/restore of cookies and localStorage across browser restarts using `--session-name` flag
- **Encrypted state** - Optional AES-256-GCM encryption for saved session state data
- **State management commands** - New commands for listing, showing, renaming, clearing, and cleaning up session state files
- **New tab on click** - Added `--new-tab` option for click commands to open links in new tabs
```bash
# Persist session state
agent-browser --session-name myapp open https://example.com
# Manage saved states
agent-browser state list
agent-browser state show myapp
agent-browser state clear myapp
```
---
## v0.9.4
<p className="text-[#888] text-sm">February 2026</p>
### Bug Fixes
- Fixed all Clippy lint warnings in the Rust CLI
---
## v0.9.3
<p className="text-[#888] text-sm">February 2026</p>
### Improvements
- Added support for custom executable path in CLI browser launch options
- Documentation site UI improvements including a new chat component with sheet-based interface
---
## v0.9.2
<p className="text-[#888] text-sm">February 2026</p>
### Improvements
- Migrated documentation site to MDX for improved content authoring
- Added AI-powered docs chat feature
- Updated README with Homebrew installation instructions for macOS users
---
## v0.9.1
<p className="text-[#888] text-sm">February 2026</p>
### New Features
- **`--allow-file-access` flag** - Enable opening and interacting with local `file://` URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files
- **`-C`/`--cursor` flag for snapshots** - Include cursor-interactive elements like divs with onclick handlers or `cursor:pointer` styles
```bash
agent-browser --allow-file-access open file:///path/to/document.pdf
agent-browser snapshot -C
```
---
## v0.9.0
<p className="text-[#888] text-sm">February 2026</p>
### New Features
- **iOS Simulator support** - Mobile Safari testing via Appium with real device and simulator support
```bash
# List available iOS simulators
agent-browser device list
# Launch on iOS device
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
# Touch interactions
agent-browser tap @e1
agent-browser swipe up
```
---
## v0.8.10
<p className="text-[#888] text-sm">January 2026</p>
### Improvements
- Added `--stdin` flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
- Fixed binary permission issues on macOS/Linux when postinstall scripts don't run
---
## v0.8.9
<p className="text-[#888] text-sm">January 2026</p>
### Improvements
- Added `--stdin` flag for eval command to read JavaScript from stdin
---
## v0.8.8
<p className="text-[#888] text-sm">January 2026</p>
### Improvements
- Added base64 encoding support for the eval command with `-b`/`--base64` flag to avoid shell escaping issues
- Updated documentation with AI agent setup instructions
---
## v0.8.7
<p className="text-[#888] text-sm">January 2026</p>
### Bug Fixes
- Fixed browser launch options not being passed correctly when using persistent profiles
- Added pre-flight checks for socket path length limits and directory write permissions
- Improved error handling to properly exit with failure status when browser launch fails
---
## v0.8.6
<p className="text-[#888] text-sm">January 2026</p>
### Bug Fixes
- Improved daemon connection reliability with automatic retry logic for transient errors
- CLI now cleans up stale socket and PID files before starting a new daemon
---
## v0.8.5
<p className="text-[#888] text-sm">January 2026</p>
### Bug Fixes
- Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases
- Made the CLI binary executable in the npm package
---
## v0.8.4
<p className="text-[#888] text-sm">January 2026</p>
### Bug Fixes
- Fixed "Daemon not found" error when running through AI agents by resolving symlinks in the executable path
---
## v0.8.3
<p className="text-[#888] text-sm">January 2026</p>
### Improvements
- Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows
- Added postinstall logic to patch npm bin entry on global installs for zero-overhead native binary invocation
- Added CI tests to verify global installation across all platforms
---
## v0.8.2
<p className="text-[#888] text-sm">January 2026</p>
### Bug Fixes
- Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js
- Added retry logic to CI install command for transient browser installation failures
---
## v0.8.1
<p className="text-[#888] text-sm">January 2026</p>
### Improvements
- Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install
- Updated documentation site with a new mobile navigation system
---
## v0.8.0
<p className="text-[#888] text-sm">January 2026</p>
### New Features
- **Kernel cloud browser provider** - Connect to Kernel (kernel.sh) for remote browser infrastructure with stealth mode and persistent profiles
```bash
# Via -p flag
agent-browser -p kernel open https://example.com
# Via environment variable
export AGENT_BROWSER_PROVIDER=kernel
export KERNEL_API_KEY=your-api-key
agent-browser open https://example.com
# With persistent profile
export KERNEL_PROFILE_NAME=my-profile
agent-browser open https://example.com
```
- **Ignore HTTPS certificate errors** - New flag for working with self-signed certificates and development environments
```bash
agent-browser --ignore-https-errors open https://localhost:3000
```
- **Enhanced cookie management** - Extended `cookies set` command with additional flags for setting cookies before page load
```bash
agent-browser cookies set session_id "abc123" --url https://app.example.com --httpOnly --secure
agent-browser cookies set token "xyz" --domain .example.com --path /api --expires 1735689600
```
### Bug Fixes
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links
- Fixed `check` command hanging indefinitely
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly
- Fixed state load and profile persistence not working in v0.7.6
- Screenshots now save to temp directory when no path is provided
### Security
- Daemon and stream server now reject cross-origin connections
---
## v0.7.1
<p className="text-[#888] text-sm">January 2026</p>
### Bug Fixes
- **Fix native binary distribution** - Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
---
## v0.7.0
<p className="text-[#888] text-sm">January 2026</p>
### New Features
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure
```bash
# Via -p flag (recommended)
agent-browser -p browserbase open https://example.com
agent-browser -p browseruse open https://example.com
# Via environment variable
export AGENT_BROWSER_PROVIDER=browserbase
agent-browser open https://example.com
```
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts
```bash
agent-browser --profile ~/.myapp-profile open myapp.com
# Login persists across restarts
```
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket
```bash
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot
```
- **`download` command** - Trigger downloads and wait for completion
```bash
agent-browser download @e1 ./file.pdf
agent-browser wait --download ./output.zip --timeout 30000
```
- **Browser launch configuration** - Fine-grained control over browser startup
```bash
agent-browser --args "--disable-gpu,--no-sandbox" open example.com
agent-browser --user-agent "Custom UA" open example.com
agent-browser --proxy-bypass "localhost,*.internal" open example.com
```
- **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)
- Windows binary path fix (.exe extension)
- 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
---
## v0.6.0
<p className="text-[#888] text-sm">January 2026</p>
### New Features
- **Video recording** - Record browser sessions to WebM using Playwright's native recording
```bash
agent-browser record start ./demo.webm
agent-browser click @e1
agent-browser record stop
```
- **`connect` command** - Connect to a browser via CDP and persist the connection for subsequent commands
```bash
agent-browser connect 9222
agent-browser snapshot # No --cdp needed after connect
```
- **`--proxy` flag** - Configure browser proxy with optional authentication
```bash
agent-browser --proxy http://user:pass@proxy.com:8080 open example.com
```
- **`get styles` command** - Extract computed styles from elements
```bash
agent-browser get styles "button"
```
- **Claude marketplace plugin** - Added `.claude-plugin/marketplace.json` for Claude Code integration
- **Enhanced network output** - `network requests` now shows method, URL, and resource type
- **`--version` flag** - Display CLI version
### Bug Fixes
- Fix Windows daemon startup and port calculation
- Support `libasound2t64` on newer Ubuntu versions (24.04+)
- Prevent CDP timeout on empty URL tabs
- Output screenshot as base64 when no path provided
- Resolve refs in `get value` command
- Support URL parameter in `tab new` command
- Allow `about:`, `data:`, and `file:` URL schemes
- Detect stale unix socket by attempting connection
- Respect `AGENT_BROWSER_HEADED` environment variable
- Handle SIGPIPE to prevent panic when piping to `head`/`tail`
- Fix null path validation in screenshot command
### Protocol Alignment
These changes align the CLI with the daemon protocol for consistency:
- `select` command now uses `values` field (supports multiple selections)
- `frame main` uses `mainframe` action
- `mouse wheel` uses `wheel` action
- `set media` uses `emulatemedia` action
- Console output uses `messages` field
### Documentation
- Expanded SKILL.md with comprehensive command reference
- Updated README with new commands and options
- Updated CDP mode documentation with `connect` workflow
-342
View File
@@ -1,342 +0,0 @@
import { pageMetadata } from '@/lib/page-metadata';
export const metadata = pageMetadata('commands');
# Commands
Executable aliases: `agent-browser`, `agent-browser-stealth`, `abs`.
## Core
```bash
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser --risk-mode block open <url> # Block when verification/captcha interstitial is detected
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 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 inserttext <text> # Insert text without key events
agent-browser keydown <key> # Hold key down
agent-browser keyup <key> # Release key
agent-browser hover <sel> # Hover element
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, --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 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 doctor # Diagnose CDP + sourceURL + tab-group plugin health
agent-browser --version # Show CLI version
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)
```
## Get info
```bash
agent-browser get text <sel> # Get text content
agent-browser get html <sel> # Get innerHTML
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 count <sel> # Count matching elements
agent-browser get box <sel> # Get bounding box
agent-browser get styles <sel> # Get computed styles
```
## Check state
```bash
agent-browser is visible <sel> # Check if visible
agent-browser is enabled <sel> # Check if enabled
agent-browser is checked <sel> # Check if checked
```
## Find elements
Semantic locators with actions (`click`, `fill`, `type`, `hover`, `focus`, `check`, `uncheck`, `text`):
```bash
agent-browser find role <role> <action> [value]
agent-browser find text <text> <action>
agent-browser find label <label> <action> [value]
agent-browser find placeholder <ph> <action> [value]
agent-browser find alt <text> <action>
agent-browser find title <text> <action>
agent-browser find testid <id> <action> [value]
agent-browser find first <sel> <action> [value]
agent-browser find last <sel> <action> [value]
agent-browser find nth <n> <sel> <action> [value]
```
Options:
- `--name <name>` -- filter role by accessible name
- `--exact` -- require exact text match
Examples:
```bash
agent-browser find role button click --name "Submit"
agent-browser find label "Email" fill "test@test.com"
agent-browser find alt "Logo" click
agent-browser find first ".item" click
agent-browser find last ".item" text
agent-browser find nth 2 ".card" hover
```
## Wait
```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 --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
```
## Risk Mode
Control how `open`/`navigate` handles verification or captcha interstitials:
```bash
agent-browser --risk-mode warn open https://example.com # default: wait for auto-clear, then retry/warn with riskSignals
agent-browser --risk-mode block open https://example.com # fail fast on detection
agent-browser --risk-mode off open https://example.com # disable detection/retry
```
## Downloads
```bash
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.
## Tab grouping
```bash
agent-browser open https://example.com
# CDP mode groups tabs when tab-group plugin is installed
# Override the default group title
agent-browser --tab-group "My Agent Group" open https://example.com
```
CDP mode uses a browser extension handshake to group tabs.
- Extension available: tabs are grouped by `session`.
- Extension missing/unavailable: silent no-op (commands still succeed).
- Default titles:
- `default` session: `Agent Browser Stealth`
- non-default: `Agent Browser Stealth • <session>`
- Extension side panel (`agent-browser-stealth`) also provides:
- Session window isolation and deterministic group colors.
- `Keep Only This`, `Focus`, `Clean Empty Groups` quick actions.
- Toggle switches for strict isolation / activation guard / auto-clean.
- Session allowlist editing (domain fallback to `about:blank` when violated).
- Download routing to `agent-browser-stealth/<session>/...`.
- Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title.
- Use `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` (or `--tab-group-plugin-id`) to override expected extension ID.
## Mouse
```bash
agent-browser mouse move <x> <y> # Move mouse
agent-browser mouse down [button] # Press button
agent-browser mouse up [button] # Release button
agent-browser mouse wheel <dy> [dx] # Scroll wheel
```
## Settings
```bash
agent-browser set viewport <w> <h> # Set viewport size
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
agent-browser set headers <json> # Extra HTTP headers
agent-browser set credentials <u> <p> # HTTP basic auth
agent-browser set media [dark|light] # Emulate color scheme (persists for session)
```
Use `--color-scheme` for persistent dark/light mode across all commands:
```bash
agent-browser --color-scheme dark open https://example.com
```
## Cookies & storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set <name> <val> # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local <key> # Get specific key
agent-browser storage local set <k> <v> # Set value
agent-browser storage local clear # Clear all
agent-browser storage session # Same for sessionStorage
```
For `cookies set`, use one of these patterns:
- `--url <url>`
- `--domain <domain> --path <path>`
- omit all three to scope from the current page URL
When `--url` is omitted, `--domain` and `--path` must be provided together.
## Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body <json> # Mock response
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
```
## Tabs & frames
```bash
agent-browser tab # List tabs
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 main # Back to main frame
```
## Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog (with optional prompt text)
agent-browser dialog dismiss # Dismiss dialog
```
## Debug
```bash
agent-browser trace start [path] # Start trace
agent-browser trace stop [path] # Stop and save trace
agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop [path] # Stop and save profile (.json)
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 --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 doctor # Diagnose CDP + sourceURL + plugin handshake status
pnpm run check:turnstile-testkey # Deterministic Turnstile smoke check (official test key)
```
## State management
```bash
agent-browser state save <path> # Save auth state to file
agent-browser state load <path> # Load auth state from file
agent-browser state list # List saved state files
agent-browser state show <file> # Show state summary
agent-browser state rename <old> <new> # Rename state file
agent-browser state clear [name] # Clear states for session name
agent-browser state clear --all # Clear all saved states
agent-browser state clean --older-than <days> # Delete old states
```
## Sessions
```bash
agent-browser session # Show current session name
agent-browser session list # List active sessions
```
## Navigation
```bash
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
```
## Global options
```bash
--session <name> # Isolated browser session
--session-name <name> # Auto-save/restore session state (defaults to --session when omitted)
--state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable
--extension <path> # Load browser extension (repeatable)
--args <args> # Browser launch args (comma separated)
--user-agent <ua> # Custom User-Agent string
--proxy <url> # Proxy server URL
--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)
--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
--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
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
--wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
--debug # Debug output (includes stealth connection type + capabilities)
```
## 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:
```bash
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "pass" && agent-browser click @e3
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
```
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact with those refs).
## Local files
Open local files (PDFs, HTML) using `file://` URLs:
```bash
agent-browser --allow-file-access open file:///path/to/document.pdf
agent-browser --allow-file-access open file:///path/to/page.html
agent-browser screenshot output.png
```
The `--allow-file-access` flag enables JavaScript to access other local files. Chromium only.
-546
View File
@@ -1,546 +0,0 @@
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 auto-attaches to an existing browser by trying `localhost:9333` (CDP) first, then auto-discovery. If both fail, commands exit instead of launching a managed browser.
## Config File Locations
agent-browser checks two locations, merged in priority order:
<table>
<thead>
<tr>
<th>Priority</th>
<th>Location</th>
<th>Scope</th>
</tr>
</thead>
<tbody>
<tr>
<td>1 (lowest)</td>
<td>
<code>~/.agent-browser/config.json</code>
</td>
<td>User-level defaults</td>
</tr>
<tr>
<td>2</td>
<td>
<code>./agent-browser.json</code>
</td>
<td>Project-level overrides</td>
</tr>
<tr>
<td>3</td>
<td>
<code>AGENT_BROWSER_*</code> env vars
</td>
<td>Override config values</td>
</tr>
<tr>
<td>4 (highest)</td>
<td>CLI flags</td>
<td>Override everything</td>
</tr>
</tbody>
</table>
Project-level values override user-level values. Environment variables override both. CLI flags always win.
Use `--config <path>` or the `AGENT_BROWSER_CONFIG` environment variable to load a specific config file instead of the default locations:
```bash
agent-browser --config ./ci-config.json open example.com
AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
```
## Example Config
```json
{
"headed": true,
"proxy": "http://localhost:8080",
"userAgent": "my-agent/1.0",
"ignoreHttpsErrors": true
}
```
## All Options
Every CLI flag can be set in the config file using its camelCase equivalent:
<table>
<thead>
<tr>
<th>Config Key</th>
<th>CLI Flag</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>headed</code>
</td>
<td>
<code>--headed</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>json</code>
</td>
<td>
<code>--json</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>full</code>
</td>
<td>
<code>--full, -f</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>debug</code>
</td>
<td>
<code>--debug</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>session</code>
</td>
<td>
<code>--session</code>
</td>
<td>string</td>
</tr>
<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>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>
<tr>
<td>
<code>args</code>
</td>
<td>
<code>--args</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>userAgent</code>
</td>
<td>
<code>--user-agent</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>provider</code>
</td>
<td>
<code>-p, --provider</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>device</code>
</td>
<td>
<code>--device</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>ignoreHttpsErrors</code>
</td>
<td>
<code>--ignore-https-errors</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>allowFileAccess</code>
</td>
<td>
<code>--allow-file-access</code>
</td>
<td>boolean</td>
</tr>
<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>tabGroup</code>
</td>
<td>
<code>--tab-group</code>
</td>
<td>string (base title for session tab grouping via CDP plugin handshake)</td>
</tr>
<tr>
<td>
<code>tabGroupPluginId</code>
</td>
<td>
<code>--tab-group-plugin-id</code>
</td>
<td>string (expected extension ID for tab-group plugin handshake)</td>
</tr>
<tr>
<td>
<code>riskMode</code>
</td>
<td>
<code>--risk-mode</code>
</td>
<td>
string (<code>off</code>, <code>warn</code>, <code>block</code>)
</td>
</tr>
<tr>
<td>
<code>headers</code>
</td>
<td>
<code>--headers</code>
</td>
<td>string (JSON)</td>
</tr>
</tbody>
</table>
`riskMode` defaults to `warn` when unset.
For tab grouping in CDP mode, grouping is best-effort through the extension handshake:
extension available => grouped by session; extension missing/unavailable => silent no-op.
With the `agent-browser-stealth` extension installed, the side panel also exposes
session window isolation controls, activation guard toggles, empty-group cleanup, and per-session allowlist policy editing.
## Common Configurations
### Local Development
```json
{
"headed": true,
"sessionName": "local-dev"
}
```
### Behind a Proxy
```json
{
"proxy": "http://proxy.corp.example.com:8080",
"proxyBypass": "localhost,*.internal.com",
"ignoreHttpsErrors": true
}
```
### CI / Devcontainer
```json
{
"args": "--no-sandbox,--disable-gpu",
"ignoreHttpsErrors": true
}
```
### iOS Testing
```json
{
"provider": "ios",
"device": "iPhone 16 Pro"
}
```
## Overriding Boolean Options
Boolean flags accept an optional `true`/`false` value to override config settings:
```bash
agent-browser --headed false open example.com
```
A bare flag is equivalent to passing `true`:
```bash
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`.
## Extensions Merging
Extensions from user-level and project-level configs are **concatenated**, not replaced. For example, if `~/.agent-browser/config.json` specifies `["/ext1"]` and `./agent-browser.json` specifies `["/ext2"]`, the result is `["/ext1", "/ext2"]`.
The `AGENT_BROWSER_EXTENSIONS` environment variable and CLI `--extension` flags follow the standard priority rules (env replaces config, CLI appends).
## Environment Variables
These environment variables configure additional daemon and runtime behavior:
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
<th>Default</th>
</tr>
</thead>
<tbody>
<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_DOWNLOAD_PATH</code>
</td>
<td>Default directory for browser downloads.</td>
<td>(temp directory)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_TAB_GROUP</code>
</td>
<td>Base title for tab grouping. Session suffix is appended automatically in CDP mode.</td>
<td>
<code>Agent Browser Stealth</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_TAB_GROUP_PLUGIN_ID</code>
</td>
<td>Expected extension ID for CDP tab-group plugin handshake.</td>
<td>
<code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_RISK_MODE</code>
</td>
<td>
Verification/captcha handling mode (<code>off</code>, <code>warn</code>, <code>block</code>
).
</td>
<td>
<code>warn</code>
</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_SESSION_NAME</code>
</td>
<td>
Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when
unset).
</td>
<td>
(same as <code>AGENT_BROWSER_SESSION</code>)
</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_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>
</tbody>
</table>
## Error Handling
- **Auto-discovered config files** (`~/.agent-browser/config.json`, `./agent-browser.json`) that are missing are silently ignored.
- **`--config <path>`** with a missing or malformed file exits with an error.
- **Malformed JSON** in auto-discovered files prints a warning to stderr and continues without that file.
- **Unknown keys** are silently ignored for forward compatibility.
> **Tip:** If your project-level `agent-browser.json` contains environment-specific values (paths, proxies), consider adding it to `.gitignore`.
-179
View File
@@ -1,179 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("diffing")
import { DiffDemo } from "@/components/diff-demo"
# Diffing
Compare page states to detect changes -- structurally via accessibility tree snapshots, visually via pixel comparison, or across two different URLs.
<DiffDemo />
## Commands
<table>
<thead>
<tr><th>Command</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>diff snapshot</code></td><td>Compare current snapshot to last snapshot in session</td></tr>
<tr><td><code>diff snapshot --baseline &lt;file&gt;</code></td><td>Compare current snapshot to a saved file</td></tr>
<tr><td><code>diff screenshot --baseline &lt;file&gt;</code></td><td>Visual pixel diff against a baseline image</td></tr>
<tr><td><code>diff url &lt;url1&gt; &lt;url2&gt;</code></td><td>Compare two pages (snapshot + optional screenshot)</td></tr>
</tbody>
</table>
## Snapshot diff
Compares the accessibility tree between two points in time using a line-level text diff.
```bash
# Compare against the last snapshot taken in this session
agent-browser diff snapshot
# Compare against a saved baseline file
agent-browser diff snapshot --baseline before.txt
# Scope to a specific part of the page
agent-browser diff snapshot --selector "#main" --compact
```
Without `--baseline`, the command automatically compares against the most recent snapshot taken in the current session. This is the primary use case for agents verifying that an action had the intended effect.
### Options
<table>
<thead>
<tr><th>Flag</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>-b, --baseline &lt;file&gt;</code></td><td>Path to a saved snapshot file to compare against</td></tr>
<tr><td><code>-s, --selector &lt;sel&gt;</code></td><td>Scope the current snapshot to a CSS selector or @ref</td></tr>
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
<tr><td><code>-d, --depth &lt;n&gt;</code></td><td>Limit snapshot tree depth</td></tr>
</tbody>
</table>
### Output
The diff uses `+` for added lines and `-` for removed lines, similar to unified diff format. A summary line shows the count of additions, removals, and unchanged lines.
```
- button "Submit" [ref=e2]
+ button "Submit" [ref=e2] [disabled]
3 additions, 2 removals, 41 unchanged
```
## Screenshot diff
Compares the current page screenshot against a baseline image at the pixel level. Produces a diff image with changed pixels highlighted in red.
```bash
# Basic visual diff
agent-browser diff screenshot --baseline before.png
# Save diff image to a specific path
agent-browser diff screenshot --baseline before.png --output diff.png
# Adjust threshold and scope to element
agent-browser diff screenshot --baseline before.png --threshold 0.2 --selector "#hero"
```
### Options
<table>
<thead>
<tr><th>Flag</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>-b, --baseline &lt;file&gt;</code></td><td>Baseline PNG/JPEG image to compare against (required)</td></tr>
<tr><td><code>-o, --output &lt;file&gt;</code></td><td>Path for the generated diff image (default: temp dir)</td></tr>
<tr><td><code>-t, --threshold &lt;0-1&gt;</code></td><td>Color distance threshold (default: 0.1). Higher = more tolerant</td></tr>
<tr><td><code>-s, --selector &lt;sel&gt;</code></td><td>Scope the current screenshot to an element</td></tr>
<tr><td><code>--full</code></td><td>Take a full-page screenshot</td></tr>
</tbody>
</table>
### Output
Reports the diff image path, number of different pixels, and mismatch percentage. The diff image shows unchanged pixels dimmed with changed pixels in red.
If the baseline and current images have different dimensions, the command reports a dimension mismatch instead of attempting pixel comparison.
## URL diff
Compares two pages by navigating to each in sequence and diffing the results.
```bash
# Compare two URLs (snapshot diff)
agent-browser diff url https://staging.example.com https://prod.example.com
# Include visual comparison
agent-browser diff url https://v1.example.com https://v2.example.com --screenshot
# Full-page screenshot comparison
agent-browser diff url https://v1.example.com https://v2.example.com --screenshot --full
```
The command navigates to the first URL, captures state, then navigates to the second URL and captures again. Snapshot diff is always included. Screenshot diff requires the `--screenshot` flag.
After completion, the browser remains on the second URL.
### Options
<table>
<thead>
<tr><th>Flag</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>--screenshot</code></td><td>Also perform visual screenshot comparison</td></tr>
<tr><td><code>--full</code></td><td>Use full-page screenshots</td></tr>
<tr><td><code>--wait-until &lt;strategy&gt;</code></td><td>Navigation wait strategy: <code>load</code>, <code>domcontentloaded</code>, <code>networkidle</code> (default: <code>load</code>)</td></tr>
<tr><td><code>-s, --selector &lt;sel&gt;</code></td><td>Scope snapshots to a CSS selector or @ref</td></tr>
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
<tr><td><code>-d, --depth &lt;n&gt;</code></td><td>Limit snapshot tree depth</td></tr>
</tbody>
</table>
## Use cases
### Verifying agent actions
The most common use case: confirm that an action (click, fill, submit) changed the page as expected.
```bash
agent-browser snapshot -i # Take interactive-only snapshot (baseline)
agent-browser fill @e3 "test@example.com"
agent-browser diff snapshot # Compare current snapshot to the baseline
```
### Monitoring for changes
Periodically compare a page against a saved baseline to detect updates.
```bash
# Save baseline
agent-browser open https://example.com && agent-browser snapshot > baseline.txt
# Later, check for changes
agent-browser open https://example.com && agent-browser diff snapshot --baseline baseline.txt
```
### Visual regression testing
Compare screenshots before and after a deploy to catch unintended visual changes.
```bash
agent-browser open https://staging.example.com && agent-browser screenshot baseline.png
# ... deploy happens ...
agent-browser open https://staging.example.com && agent-browser diff screenshot --baseline baseline.png
```
### Comparing environments
Diff staging against production to verify parity.
```bash
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-333
View File
@@ -1,333 +0,0 @@
@import "tailwindcss";
@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);
}
:root {
--background: #fff;
--foreground: #171717;
--border: #e5e5e5;
--muted: #f5f5f5;
--muted-foreground: #737373;
--primary: #171717;
--primary-foreground: #fff;
}
.dark {
--background: #0a0a0a;
--foreground: #f5f5f5;
--border: #262626;
--muted: #262626;
--muted-foreground: #a3a3a3;
--primary: #f5f5f5;
--primary-foreground: #0a0a0a;
}
html {
scroll-behavior: smooth;
}
::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 {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.875rem;
overflow-x: auto;
font-size: 0.8125rem;
line-height: 1.7;
}
pre:not(.shiki) {
background: var(--muted);
}
.code-block pre {
margin: 0;
}
.code-block {
margin-bottom: 1.25rem;
}
@media (max-width: 640px) {
pre {
font-size: 0.75rem;
padding: 0.75rem;
}
}
:not(pre) > code {
background: var(--muted);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.875em;
}
/* 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%;
}
.prose h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
color: var(--foreground);
}
@media (min-width: 640px) {
.prose h1 {
font-size: 1.75rem;
}
}
.prose h2 {
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: 1rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: var(--foreground);
}
.prose p {
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: 1rem;
padding-left: 1.25rem;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
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);
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;
margin: 1.5rem 0;
font-size: 0.8125rem;
}
.prose th, .prose td {
text-align: left;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.prose th {
font-weight: 500;
color: var(--muted-foreground);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.025em;
}
.prose td {
color: var(--muted-foreground);
}
.prose td code {
color: var(--foreground);
}
/* Tool call shimmer animation */
@keyframes tool-shimmer {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.animate-tool-shimmer {
animation: tool-shimmer 1.5s ease-in-out infinite;
}
/* Override prose text color in chat so agent responses use primary foreground */
.docs-chat-content p,
.docs-chat-content li,
.docs-chat-content td,
.docs-chat-content th,
.docs-chat-content strong,
.docs-chat-content code {
color: var(--foreground);
}
/* Reset global pre styles inside chat so Streamdown's own styling takes effect */
.docs-chat-content pre {
border: none;
border-radius: 0;
padding: revert-layer;
}
/* Fix list rendering in chat content */
.docs-chat-content ul,
.docs-chat-content ol {
list-style-position: outside;
padding-left: 1.25em;
}
.docs-chat-content li > p {
display: inline;
margin: 0;
}
.docs-chat-content li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
-143
View File
@@ -1,143 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("installation")
# Installation
## Global installation (recommended)
Installs the native Rust binary for maximum performance:
```bash
npm install -g agent-browser-stealth
agent-browser install # Download Chromium
```
This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead.
## Quick start (no install)
Run directly with `npx` if you want to try it without installing globally:
```bash
npx agent-browser-stealth install # Download Chromium (first time only)
npx agent-browser-stealth open example.com
```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
## Project installation (local dependency)
For projects that want to pin the version in `package.json`:
```bash
npm install agent-browser-stealth
npx agent-browser-stealth install
```
Then use via `npx` or `package.json` scripts:
```bash
npx agent-browser-stealth open example.com
```
## Homebrew (macOS)
```bash
brew install agent-browser
agent-browser install # Download Chromium
```
## From source
```bash
git clone https://github.com/leeguooooo/agent-browser
cd agent-browser
pnpm install
pnpm build
pnpm build:native
./bin/agent-browser install
pnpm link --global
```
## Fork versioning
Fork releases use a dual-version format:
- `<upstream>-fork.<fork>`
- Example: `0.14.0-fork.1`
`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds.
## Linux dependencies
On Linux, install system dependencies:
```bash
agent-browser install --with-deps
# or manually: npx playwright install-deps chromium
```
## Custom browser
Use a custom browser executable instead of bundled Chromium:
- **Serverless** - Use `@sparticuz/chromium` (~50MB vs ~684MB)
- **System browser** - Use existing Chrome installation
- **Custom builds** - Use modified browser builds
```bash
# Via flag
agent-browser --executable-path /path/to/chromium open example.com
# Via environment variable
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
```
### Serverless example
```typescript
import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser-stealth';
export async function handler() {
const browser = new BrowserManager();
await browser.launch({
executablePath: await chromium.executablePath(),
headless: true,
});
// ... use browser
}
```
## AI agent setup
agent-browser works with any AI agent out of the box. For richer context:
### AI coding assistants (recommended)
Install the skill for your AI coding assistant:
```bash
npx skills add leeguooooo/agent-browser
```
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
> **Do not** copy `SKILL.md` from `node_modules` -- it will become stale as new features are added. Always use `npx skills add` or reference the repository version.
### AGENTS.md / CLAUDE.md
Add to your instructions file:
```markdown
## Browser Automation
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
Core workflow:
1. `agent-browser open <url>` - Navigate to page
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
4. Re-snapshot after page changes
```
-211
View File
@@ -1,211 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("ios")
# iOS Simulator
Control real Mobile Safari in the iOS Simulator for authentic mobile
web testing. Uses Appium with XCUITest for native automation.
## Requirements
- macOS with Xcode installed
- iOS Simulator runtimes (download via Xcode)
- Appium with XCUITest driver
## Setup
```bash
# Install Appium globally
npm install -g appium
# Install the XCUITest driver for iOS
appium driver install xcuitest
```
## List available devices
See all iOS simulators available on your system:
```bash
agent-browser device list
# Output:
# Available iOS Simulators:
#
# ○ iPhone 16 Pro (iOS 18.0)
# F21EEC0D-7618-419F-811B-33AF27A8B2FD
# ○ iPhone 16 Pro Max (iOS 18.0)
# 50402807-C9B8-4D37-9F13-2E00E782C744
# ○ iPad Pro 13-inch (M4) (iOS 18.0)
# 3A6C6436-B909-4593-866D-91D1062BB070
# ...
```
## Basic usage
Use the `-p ios` flag to enable iOS mode. The workflow is
identical to desktop:
```bash
# Launch Safari on iPhone 16 Pro
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
# Get snapshot with refs (same as desktop)
agent-browser -p ios snapshot -i
# Interact using refs
agent-browser -p ios tap @e1
agent-browser -p ios fill @e2 "text"
# Take screenshot
agent-browser -p ios screenshot mobile.png
# Close session (shuts down simulator)
agent-browser -p ios close
```
## Mobile-specific commands
```bash
# Swipe gestures
agent-browser -p ios swipe up
agent-browser -p ios swipe down
agent-browser -p ios swipe left
agent-browser -p ios swipe right
# Swipe with distance (pixels)
agent-browser -p ios swipe up 500
# Tap (alias for click, semantically clearer for touch)
agent-browser -p ios tap @e1
```
## Environment variables
Configure iOS mode via environment variables:
```bash
export AGENT_BROWSER_PROVIDER=ios
export AGENT_BROWSER_IOS_DEVICE="iPhone 16 Pro"
# Now all commands use iOS
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser tap @e1
```
<table>
<thead>
<tr><th>Variable</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_PROVIDER</code></td><td>Set to <code>ios</code> to enable iOS mode</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Device name (e.g., "iPhone 16 Pro")</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Device UDID (alternative to device name)</td></tr>
</tbody>
</table>
## Supported devices
All iOS Simulators available in Xcode are supported, including:
- All iPhone models (iPhone 15, 16, 17, SE, etc.)
- All iPad models (iPad Pro, iPad Air, iPad mini, etc.)
- Multiple iOS versions (17.x, 18.x, etc.)
**Real devices** are also supported via USB connection (see below).
## Real device support
Appium can control Safari on real iOS devices connected via USB. This
requires additional one-time setup.
### 1. Get your device UDID
```bash
# List connected devices
xcrun xctrace list devices
# Or via system profiler
system_profiler SPUSBDataType | grep -A 5 "iPhone\|iPad"
```
### 2. Sign WebDriverAgent (one-time)
WebDriverAgent needs to be signed with your Apple Developer
certificate to run on real devices.
```bash
# Open the WebDriverAgent Xcode project
cd ~/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent
open WebDriverAgent.xcodeproj
```
In Xcode:
1. Select the `WebDriverAgentRunner` target
2. Go to Signing & Capabilities
3. Select your Team (requires Apple Developer account, free tier works)
4. Let Xcode manage signing automatically
### 3. Use with agent-browser
```bash
# Connect device via USB, then use the UDID
agent-browser -p ios --device "<DEVICE_UDID>" open https://example.com
# Or use the device name if unique
agent-browser -p ios --device "John's iPhone" open https://example.com
```
### Real device notes
- First run installs WebDriverAgent to the device (may require Trust prompt on device)
- Device must be unlocked and connected via USB
- Slightly slower initial connection than simulator
- Tests against real Safari performance and behavior
- On first install, go to Settings → General → VPN & Device Management to trust the developer certificate
## Performance notes
- **First launch:** Takes 30-60 seconds to boot the simulator and start Appium
- **Subsequent commands:** Fast (simulator stays running)
- **Close command:** Shuts down simulator and Appium server
## Differences from desktop
<table>
<thead>
<tr><th>Feature</th><th>Desktop</th><th>iOS</th></tr>
</thead>
<tbody>
<tr><td>Browser</td><td>Chromium/Firefox/WebKit</td><td>Safari only</td></tr>
<tr><td>Tabs</td><td>Supported</td><td>Single tab only</td></tr>
<tr><td>PDF export</td><td>Supported</td><td>Not supported</td></tr>
<tr><td>Screencast</td><td>Supported</td><td>Not supported</td></tr>
<tr><td>Swipe gestures</td><td>Not native</td><td>Native support</td></tr>
</tbody>
</table>
## Troubleshooting
### Appium not found
```bash
# Make sure Appium is installed globally
npm install -g appium
appium driver install xcuitest
# Verify installation
appium --version
```
### No simulators available
Open Xcode and download iOS Simulator runtimes from **Settings → Platforms**.
### Simulator won't boot
Try booting the simulator manually from Xcode or the Simulator app to
ensure it works, then retry with agent-browser.
-93
View File
@@ -1,93 +0,0 @@
import type { Metadata } from "next";
import { Inter, Geist_Mono } from "next/font/google";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import { DocsSidebar } from "@/components/docs-sidebar";
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { CopyPageButton } from "@/components/copy-page-button";
import { DocsChat } from "@/components/docs-chat";
import { cookies } from "next/headers";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Analytics } from "@vercel/analytics/next";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL("https://agent-browser.dev"),
title: {
default: "agent-browser | Headless Browser Automation for AI",
template: "%s | agent-browser",
},
description: "Headless browser automation CLI for AI agents",
openGraph: {
type: "website",
locale: "en_US",
url: "https://agent-browser.dev",
siteName: "agent-browser",
title: "agent-browser | Headless Browser Automation for AI",
description: "Headless browser automation CLI for AI agents",
images: [{ url: "/og", width: 1200, height: 630, alt: "agent-browser" }],
},
twitter: {
card: "summary_large_image",
title: "agent-browser | Headless Browser Automation for AI",
description: "Headless browser automation CLI for AI agents",
images: ["/og"],
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const cookieStore = await cookies();
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
return (
<html lang="en" suppressHydrationWarning>
<head>
{chatOpen && (
<style
dangerouslySetInnerHTML={{
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
}}
/>
)}
</head>
<body
className={`${inter.variable} ${geistMono.variable} ${GeistPixelSquare.variable} bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100`}
>
<ThemeProvider>
<Header />
<DocsMobileNav />
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
<DocsSidebar />
</aside>
<div className="flex-1 min-w-0 max-w-2xl pb-20">
<div className="flex justify-end mb-4">
<CopyPageButton />
</div>
<article className="prose">{children}</article>
</div>
</div>
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
</ThemeProvider>
<SpeedInsights />
<Analytics />
</body>
</html>
);
}
-85
View File
@@ -1,85 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("native-mode")
# Native Mode (Experimental)
agent-browser includes an experimental native Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating the Node.js and Playwright dependencies entirely.
## Enabling Native Mode
Native mode is opt-in. Enable it with the `--native` flag or the `AGENT_BROWSER_NATIVE` environment variable.
### CLI Flag
```bash
agent-browser --native open example.com
agent-browser --native snapshot
agent-browser --native close
```
### Environment Variable
Set `AGENT_BROWSER_NATIVE=1` to avoid passing the flag on every command:
```bash
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
agent-browser snapshot
agent-browser close
```
### Config File
Add `"native": true` to your `agent-browser.json`:
```json
{"native": true}
```
## Architecture Comparison
<table>
<thead>
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
</thead>
<tbody>
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
</tbody>
</table>
## What Works
All core commands are supported in native mode:
- Navigation: `open`, `back`, `forward`, `reload`
- Interaction: `click`, `fill`, `type`, `press`, `hover`, `select`, `check`, `uncheck`, `scroll`, `focus`, `clear`, `upload`, `drag`
- Observation: `snapshot`, `screenshot`, `eval`, `get text/html/value/attr/count/box/styles`, `is visible/enabled/checked`
- State: `cookies get/set/clear`, `storage local/session`, `state save/load/list`
- Tabs: `tab new/list/close`, tab switching
- Emulation: `set viewport`, `set device`, `set geo`, user agent, timezone, locale
- Streaming: WebSocket screencast and remote input
- Diffing: `diff snapshot`, `diff url`
- Recording: `record start/stop`
- Profiling: `profiler start/stop`, `trace start/stop`
## Known Limitations
- **Firefox and WebKit** are not yet supported (Chromium and Safari only)
- **Playwright trace format** is not available (native tracing uses Chrome's built-in tracing)
- **HAR export** is not available
- **Network route interception** uses CDP Fetch domain instead of Playwright's route API
## Switching Between Modes
The native daemon and Node.js daemon share the same session socket. You cannot run both simultaneously for the same session. Close the current daemon before switching:
```bash
agent-browser close
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
import { getPageTitle, renderOgImage } from "../og-image";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string[] }> },
) {
const { slug } = await params;
const title = getPageTitle(slug.join("/"));
if (!title) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return renderOgImage(title);
}
-112
View File
@@ -1,112 +0,0 @@
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export { getPageTitle } from "@/lib/page-titles";
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null =
null;
async function loadFonts() {
if (fontCache) return fontCache;
const [geistRegular, geistPixelSquare] = await Promise.all([
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
]);
fontCache = { geistRegular, geistPixelSquare };
return fontCache;
}
export async function renderOgImage(title: string) {
const { geistRegular, geistPixelSquare } = await loadFonts();
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "black",
padding: "60px 80px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
}}
>
<svg width="36" height="36" viewBox="0 0 16 16" fill="white">
<path fillRule="evenodd" clipRule="evenodd" d="M8 1L16 15H0L8 1Z" />
</svg>
<span
style={{
fontSize: 36,
color: "#666",
fontFamily: "Geist",
fontWeight: 400,
}}
>
/
</span>
<span
style={{
fontSize: 36,
fontFamily: "GeistPixelSquare",
fontWeight: 400,
color: "white",
}}
>
agent-browser
</span>
</div>
<div
style={{
display: "flex",
flex: 1,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
{title.split("\n").map((line, i) => (
<span
key={i}
style={{
fontSize: 72,
fontFamily: "Geist",
fontWeight: 400,
color: "white",
letterSpacing: "-0.02em",
textAlign: "center",
lineHeight: 1.2,
}}
>
{line}
</span>
))}
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: "Geist",
data: geistRegular.buffer as ArrayBuffer,
style: "normal",
weight: 400,
},
{
name: "GeistPixelSquare",
data: geistPixelSquare.buffer as ArrayBuffer,
style: "normal",
weight: 400,
},
],
},
);
}
-6
View File
@@ -1,6 +0,0 @@
import { getPageTitle, renderOgImage } from "./og-image";
export async function GET() {
const title = getPageTitle("")!;
return renderOgImage(title);
}
-73
View File
@@ -1,73 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("")
# agent-browser
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
```bash
npm install -g agent-browser-stealth # all platforms (fastest, native Rust CLI)
brew install agent-browser # macOS
# or try without installing
npx agent-browser-stealth open example.com
```
Executable aliases after install: `agent-browser`, `agent-browser-stealth`, and `abs`.
## Features
- **Agent-first** - Compact text output uses fewer tokens than JSON, designed for AI context efficiency
- **Ref-based** - Snapshot returns accessibility tree with refs for deterministic element selection
- **Fast** - Native Rust CLI for instant command parsing
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
- **Sessions** - Multiple isolated browser instances with separate auth
- **Cross-platform** - macOS, Linux, Windows with native binaries
- **Auto region detection** - Locale, timezone, and Accept-Language automatically match the target site's TLD
- **Captcha auto-retry** - Detects captcha/verification pages and retries with randomized backoff
## Works with
Claude Code, Cursor, GitHub Copilot, OpenAI Codex, Google Gemini, opencode, and any agent that can run shell commands.
## Example
```bash
# Navigate and get snapshot
agent-browser open example.com
agent-browser snapshot -i
# Output:
# - heading "Example Domain" [ref=e1]
# - link "More information..." [ref=e2]
# Interact using refs
agent-browser click @e2
agent-browser screenshot page.png
agent-browser close
```
## Why refs?
The `snapshot` command returns a compact accessibility tree where each element
has a unique ref like `@e1`, `@e2`. This provides:
- **Context-efficient** - Text output uses ~200-400 tokens vs ~3000-5000 for full DOM
- **Deterministic** - Ref points to exact element from snapshot
- **Fast** - No DOM re-query needed
- **AI-friendly** - LLMs parse text output naturally
## Architecture
Client-daemon architecture for optimal performance:
1. **Rust CLI** - Parses commands, communicates with daemon
2. **Node.js Daemon** (default) - Manages Playwright browser instance
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
Daemon starts automatically and persists between commands.
## Platforms
Native Rust binaries for macOS (ARM64, x64), Linux (ARM64, x64), and Windows (x64).
-114
View File
@@ -1,114 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("profiler")
# Profiler
Capture Chrome DevTools performance profiles during browser automation.
Use profiles to diagnose slow page loads, expensive JavaScript, layout thrashing,
and other performance bottlenecks in agentic workflows.
## Basic usage
```bash
# Start profiling
agent-browser profiler start
# Perform actions
agent-browser navigate https://example.com
agent-browser click "#button"
# Stop and save profile
agent-browser profiler stop ./trace.json
```
The output JSON file can be loaded into Chrome DevTools, Perfetto UI, or any
tool that accepts Chrome Trace Event format.
## Commands
<table>
<thead>
<tr><th>Command</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>profiler start</code></td><td>Start recording a performance profile</td></tr>
<tr><td><code>profiler start --categories &lt;list&gt;</code></td><td>Start with custom trace categories</td></tr>
<tr><td><code>profiler stop [path]</code></td><td>Stop profiling and save to file</td></tr>
</tbody>
</table>
## Trace categories
The `--categories` flag accepts a comma-separated list of Chrome trace categories.
```bash
agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing"
```
Default categories include `devtools.timeline`, `v8.execute`, `blink`,
`blink.user_timing`, `latencyInfo`, `renderer.scheduler`, `toplevel`, and
several `disabled-by-default-*` categories for detailed CPU profiling and
call stack analysis.
### Common categories
<table>
<thead>
<tr><th>Category</th><th>What it captures</th></tr>
</thead>
<tbody>
<tr><td><code>devtools.timeline</code></td><td>Standard DevTools performance events</td></tr>
<tr><td><code>v8.execute</code></td><td>Time spent running JavaScript</td></tr>
<tr><td><code>blink</code></td><td>Renderer events (layout, paint, style)</td></tr>
<tr><td><code>blink.user_timing</code></td><td><code>performance.mark()</code> and <code>performance.measure()</code> calls</td></tr>
<tr><td><code>latencyInfo</code></td><td>Input-to-display latency</td></tr>
<tr><td><code>disabled-by-default-v8.cpu_profiler</code></td><td>Sampling-based JS CPU profiling</td></tr>
</tbody>
</table>
## Output format
The output is a JSON file in Chrome Trace Event format:
```json
{
"traceEvents": [
{
"cat": "devtools.timeline",
"name": "RunTask",
"ph": "X",
"ts": 12345,
"dur": 100,
"pid": 1,
"tid": 1
}
],
"metadata": {
"clock-domain": "LINUX_CLOCK_MONOTONIC"
}
}
```
The `metadata.clock-domain` field reflects the host platform (Linux or macOS).
On Windows it is omitted.
## Viewing profiles
- **Chrome DevTools** -- Performance panel > Load profile
- **Perfetto** -- https://ui.perfetto.dev/ (drag and drop the JSON file)
- **Trace Viewer** -- `chrome://tracing` in any Chromium browser
## Use cases
- **Page load analysis** -- Profile navigation to identify slow resources, long tasks, or layout shifts
- **Interaction profiling** -- Measure the cost of clicks, form fills, and other user interactions
- **CI regression checks** -- Capture profiles per build and compare trace data over time
- **Agent workflow optimization** -- Find which steps in an agentic flow are most expensive
## Limitations
- Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit.
- Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest.
- Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail.
- When no output path is provided, the profile is saved to an auto-generated path under the agent-browser temp directory.
-94
View File
@@ -1,94 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("quick-start")
# Quick Start
## Core workflow
Every browser automation follows this pattern:
```bash
# 1. Navigate
agent-browser open example.com
# 2. Snapshot to get element refs
agent-browser snapshot -i
# Output:
# @e1 [heading] "Example Domain"
# @e2 [link] "More information..."
# 3. Interact using refs
agent-browser click @e2
# 4. Re-snapshot after page changes
agent-browser snapshot -i
```
## Common commands
```bash
agent-browser open example.com
agent-browser snapshot -i # Get interactive elements with refs
agent-browser click @e2 # Click by ref
agent-browser fill @e3 "test@example.com" # Fill input by ref
agent-browser get text @e1 # Get text content
agent-browser screenshot # Save to temp directory
agent-browser screenshot page.png # Save to specific path
agent-browser close
```
## Traditional selectors
CSS selectors and semantic locators also supported:
```bash
agent-browser click "#submit"
agent-browser fill "#email" "test@example.com"
agent-browser find role button click --name "Submit"
```
## Headed mode
Show browser window for debugging:
```bash
agent-browser open example.com --headed
```
## Wait for content
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
```
## Command chaining
Chain commands with `&&` in a single shell call. The browser persists via a background daemon, so chaining is safe and efficient:
```bash
# Open, wait, and snapshot in one call
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
# Chain multiple interactions
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "pass" && agent-browser click @e3
# Navigate and capture
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
```
Use `&&` when you don't need intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs before interacting).
## JSON output
For programmatic parsing in scripts:
```bash
agent-browser snapshot --json
agent-browser get text @e1 --json
```
Note: The default text output is more compact and preferred for AI agents.
-243
View File
@@ -1,243 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("security")
# Security
agent-browser includes security features to protect against credential exposure, prompt injection via untrusted page content, and unauthorized browser actions.
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. Enable these features as needed for your deployment -- existing workflows are unaffected until you explicitly activate a feature.
## Threat Model
These features are designed to mitigate the following threats when an LLM-based agent drives a browser:
- **Credential exposure** -- Passwords stored in the auth vault are never included in LLM context. The CLI handles vault operations locally; credentials do not pass through the daemon's IPC channel.
- **Prompt injection via page content** -- Malicious pages can embed text that looks like tool output or system instructions. Content boundary markers (`--content-boundaries`) let the orchestrator distinguish trusted tool output from untrusted page content.
- **Unauthorized navigation / data exfiltration** -- A compromised or manipulated agent could navigate to attacker-controlled domains to exfiltrate data. The domain allowlist (`--allowed-domains`) blocks navigations, sub-resource requests, WebSocket connections, EventSource streams, and `sendBeacon` calls to non-allowed domains.
- **Unauthorized destructive actions** -- Action policy (`--action-policy`) and confirmation gating (`--confirm-actions`) prevent the agent from performing dangerous operations (eval, downloads, uploads) without explicit approval.
- **Context flooding** -- Large page outputs can overwhelm an LLM's context window. Output truncation (`--max-output`) caps the size of page-sourced content.
### Known limitations
- **WebSocket/EventSource blocking is best-effort.** It works by overriding browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. Deny `eval` via `--action-policy` for maximum protection.
- **Domain filter timing on remote connections.** When connecting to a pre-existing browser via CDP or a cloud provider, pages may have already loaded content before the domain filter is installed. agent-browser navigates disallowed pages to `about:blank` after the filter is active, but resources loaded before that point are not retroactively blocked.
- **Content boundaries are defense-in-depth.** They rely on the LLM and orchestrator respecting the structural markers. A sufficiently capable adversarial page could attempt to mimic the boundary format, though the per-process CSPRNG nonce makes this impractical to predict.
- **Confirmation timeout.** Pending confirmations auto-deny after 60 seconds. Orchestrators must respond within that window.
- **Non-TTY auto-deny.** When `--confirm-interactive` is set but stdin is not a terminal (e.g., piped input), actions are automatically denied to prevent accidental approval in non-interactive contexts.
## Authentication Vault
Store credentials locally and reference them by name. The LLM never sees passwords.
```bash
# Save credentials (encrypted if AGENT_BROWSER_ENCRYPTION_KEY is set)
# Recommended: pipe password via stdin to avoid shell history / process listing exposure
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
# Or pass directly (a warning will be shown)
agent-browser auth save github --url https://github.com/login --username user --password pass
# Login using saved credentials
agent-browser auth login github
# List saved profiles (names and URLs only, no secrets)
agent-browser auth list
# Show profile metadata
agent-browser auth show github
# Delete a profile
agent-browser auth delete github
```
Custom selectors can be specified if auto-detection fails:
```bash
agent-browser auth save myapp \
--url https://app.example.com/login \
--username user --password pass \
--username-selector "#email" \
--password-selector "#password" \
--submit-selector "button.login"
```
Profiles are stored in `~/.agent-browser/auth/` and always encrypted with AES-256-GCM. If `AGENT_BROWSER_ENCRYPTION_KEY` is not set, a key is auto-generated at `~/.agent-browser/.encryption-key` on first use. Back up this file or set the environment variable explicitly for portability.
File permissions are enforced on both Unix (`chmod 600`/`700`) and Windows (`icacls` restricted to the current user) to prevent other users from reading encryption keys or auth profiles.
## Content Boundary Markers
When `--content-boundaries` is enabled, all page-sourced output is wrapped in structural markers so LLMs can distinguish tool output from untrusted page content:
```
--- AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 origin=https://example.com ---
[snapshot / text / html / eval output here]
--- END_AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 ---
```
The nonce is a random value generated per CLI process invocation, making it unpredictable to page content that might attempt to spoof the boundary.
Enable via flag or environment variable:
```bash
agent-browser --content-boundaries snapshot
# or
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
```
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
In `--json` mode, boundary metadata is injected into the JSON response as a `_boundary` object containing `nonce` and `origin` fields, allowing orchestrators to verify provenance programmatically:
```json
{
"success": true,
"data": { "snapshot": "...", "origin": "https://example.com" },
"_boundary": { "nonce": "a1b2c3d4e5f6...", "origin": "https://example.com" }
}
```
## Domain Allowlist
Restrict which domains the browser can interact with, preventing redirect-based attacks and data exfiltration:
```bash
agent-browser --allowed-domains "example.com,*.example.com,github.com" open https://example.com
# or
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
```
Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked, preventing data exfiltration. WebSocket and EventSource connections are also blocked via constructor-level patching. Non-http(s) sub-resources (data URIs, blobs) are still allowed. When a request is blocked, the command returns an error.
> **Note:** The WebSocket/EventSource blocking is best-effort -- it works by overriding the browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. For maximum protection, deny the `eval` category via `--action-policy` when using `--allowed-domains`.
Config file:
```json
{
"allowedDomains": ["example.com", "*.example.com", "github.com"]
}
```
> **CDN and third-party resources:** The domain filter blocks all sub-resource requests (scripts, stylesheets, images, fonts, fetch/XHR) to non-allowed domains. Most websites load assets from CDN domains. Include these in your allowlist or pages will break. For example:
>
> ```bash
> --allowed-domains "myapp.com,*.myapp.com,cdn.jsdelivr.net,fonts.googleapis.com,fonts.gstatic.com"
> ```
## Action Policy
Gate actions using a static policy file. The policy is enforced by the daemon -- denied actions fail immediately.
```bash
agent-browser --action-policy ./policy.json open https://example.com
# or
export AGENT_BROWSER_ACTION_POLICY=./policy.json
```
Example policy (permissive with specific denials):
```json
{
"default": "allow",
"deny": ["eval", "download", "upload"]
}
```
Example policy (restrictive):
```json
{
"default": "deny",
"allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]
}
```
<table>
<thead>
<tr><th>Category</th><th>Actions</th></tr>
</thead>
<tbody>
<tr><td><code>navigate</code></td><td>open, back, forward, reload, tab new</td></tr>
<tr><td><code>click</code></td><td>click, dblclick, tap</td></tr>
<tr><td><code>fill</code></td><td>fill, type, keyboard type/inserttext, select, check, uncheck</td></tr>
<tr><td><code>eval</code></td><td>eval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent</td></tr>
<tr><td><code>download</code></td><td>download, waitfordownload</td></tr>
<tr><td><code>upload</code></td><td>upload</td></tr>
<tr><td><code>snapshot</code></td><td>snapshot, screenshot, pdf, diff</td></tr>
<tr><td><code>scroll</code></td><td>scroll, scrollintoview</td></tr>
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
</tbody>
</table>
Auth vault operations (`auth save`, `auth login`, `auth list`, `auth show`, `auth delete`) and other internal/meta operations bypass action policy enforcement since they are trusted local operations. Domain allowlist restrictions still apply to `auth login` navigations.
## Action Confirmation
For actions that require explicit approval, use `--confirm-actions` to specify categories that require confirmation:
```bash
# Orchestrator mode: returns confirmation_required response
agent-browser --confirm-actions eval,download eval "document.title"
# Then approve or deny:
agent-browser confirm c_8f3a1234
agent-browser deny c_8f3a1234
```
For interactive (human-in-the-loop) confirmation:
```bash
agent-browser --confirm-actions eval,download --confirm-interactive eval "document.title"
# Prompts: Allow? [y/N]
```
Pending confirmations auto-deny after 60 seconds.
> **Non-TTY behavior:** When `--confirm-interactive` is set but stdin is not a TTY (e.g., piped input or running inside an automated pipeline), actions are automatically denied. This prevents accidental approval in non-interactive contexts.
## Output Length Limits
Prevent context flooding by truncating large page outputs:
```bash
agent-browser --max-output 50000 get text body
# or
export AGENT_BROWSER_MAX_OUTPUT=50000
```
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
## Environment Variables
<table>
<thead>
<tr><th>Variable</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers</td></tr>
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns</td></tr>
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts</td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption (auth vault + sessions)</td></tr>
</tbody>
</table>
## Recommended Configuration
For production AI agent deployments:
```json
{
"contentBoundaries": true,
"maxOutput": 50000,
"allowedDomains": ["your-app.com", "*.your-app.com"],
"actionPolicy": "./policy.json"
}
```
-58
View File
@@ -1,58 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("selectors")
# Selectors
## Refs (recommended)
Refs provide deterministic element selection from snapshots. Best for AI agents.
```bash
# 1. Get snapshot with refs
agent-browser snapshot
# Output:
# - heading "Example Domain" [ref=e1] [level=1]
# - button "Submit" [ref=e2]
# - textbox "Email" [ref=e3]
# - link "Learn more" [ref=e4]
# 2. Use refs to interact
agent-browser click @e2 # Click the button
agent-browser fill @e3 "test@example.com" # Fill the textbox
agent-browser get text @e1 # Get heading text
agent-browser hover @e4 # Hover the link
```
### Why refs?
- **Deterministic** - Ref points to exact element from snapshot
- **Fast** - No DOM re-query needed
- **AI-friendly** - LLMs can reliably parse and use refs
## CSS selectors
```bash
agent-browser click "#id"
agent-browser click ".class"
agent-browser click "div > button"
agent-browser click "[data-testid='submit']"
```
## Text & XPath
```bash
agent-browser click "text=Submit"
agent-browser click "xpath=//button[@type='submit']"
```
## Semantic locators
Find elements by role, label, or other semantic properties:
```bash
agent-browser find role button click --name "Submit"
agent-browser find label "Email" fill "test@test.com"
agent-browser find placeholder "Search..." fill "query"
agent-browser find testid "submit-btn" click
```
-175
View File
@@ -1,175 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("sessions")
# Sessions
Run multiple isolated browser instances:
```bash
# Different sessions
agent-browser --session agent1 open site-a.com
agent-browser --session agent2 open site-b.com
# Or via environment variable
AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
# List active sessions
agent-browser session list
# Output:
# Active sessions:
# -> default
# agent1
# Show current session
agent-browser session
```
## Session isolation
Each session has its own:
- Browser instance
- Cookies and storage
- Navigation history
- Authentication state
## Session persistence
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
```bash
# Auto-save/load state for "twitter" session
agent-browser --session-name twitter open twitter.com
# Login once, then state persists automatically
agent-browser --session-name twitter click "#login"
# Or via environment variable
export AGENT_BROWSER_SESSION_NAME=twitter
agent-browser open twitter.com
```
If `--session-name` is omitted, it defaults to `--session` (or `default`).
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
### Session name rules
Session names must contain only alphanumeric characters, hyphens, and underscores:
```bash
# Valid session names
agent-browser --session-name my-project open example.com
agent-browser --session-name test_session_v2 open example.com
# Invalid (will be rejected)
agent-browser --session-name "../bad" open example.com # path traversal
agent-browser --session-name "my session" open example.com # spaces
agent-browser --session-name "foo/bar" open example.com # slashes
```
## State encryption
Encrypt saved state files (cookies, localStorage) using AES-256-GCM:
```bash
# Generate a 256-bit key (64 hex characters)
openssl rand -hex 32
# Set the encryption key
export AGENT_BROWSER_ENCRYPTION_KEY=<your-64-char-hex-key>
# State files are now encrypted automatically
agent-browser --session-name secure-session open example.com
# List states shows encryption status
agent-browser state list
```
## State auto-expiration
Automatically delete old state files to prevent accumulation:
```bash
# Set expiration (default: 30 days)
export AGENT_BROWSER_STATE_EXPIRE_DAYS=7
# Manually clean old states
agent-browser state clean --older-than 7
```
## State management commands
```bash
# List all saved states
agent-browser state list
# Show state summary (cookies, origins, domains)
agent-browser state show my-session-default.json
# Rename a state file
agent-browser state rename old-name new-name
# Clear states for a specific session name
agent-browser state clear my-session
# Clear all saved states
agent-browser state clear --all
# Manual save/load (for custom paths)
agent-browser state save ./backup.json
agent-browser state load ./backup.json
```
## Authenticated sessions
Use `--headers` to set HTTP headers for a specific origin:
```bash
# Headers scoped to api.example.com only
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
# Requests to api.example.com include the auth header
agent-browser snapshot -i --json
agent-browser click @e2
# Navigate to another domain - headers NOT sent
agent-browser open other-site.com
```
Useful for:
- **Skipping login flows** - Authenticate via headers
- **Switching users** - Different auth tokens per session
- **API testing** - Access protected endpoints
- **Security** - Headers scoped to origin, not leaked
## Multiple origins
```bash
agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'
```
## Global headers
For headers on all domains:
```bash
agent-browser set headers '{"X-Custom-Header": "value"}'
```
## Environment variables
<table>
<thead>
<tr><th>Variable</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_SESSION</code></td><td>Browser session ID (default: "default")</td></tr>
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name</td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption</td></tr>
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete states older than N days (default: 30)</td></tr>
</tbody>
</table>
-60
View File
@@ -1,60 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("skills")
# Skills
agent-browser ships with skills that teach AI coding agents how to use it for specific workflows. Install a skill and your agent in Cursor, Claude Code, or Codex can automate browser tasks without manual guidance.
## Available Skills
- **agent-browser** — General browser automation: navigation, snapshots, forms, screenshots, data extraction, sessions, authentication, diffing, and the full command reference.
- **dogfood** — Systematic exploratory testing. Navigates an app like a real user, finds bugs and UX issues, and produces a structured report with screenshots and repro videos.
- **electron** — Automate any Electron app (VS Code, Slack, Discord, Figma, etc.) by connecting to its built-in Chrome DevTools Protocol port. This is how agent-browser drives native desktop apps like the Slack macOS app.
- **slack** — Browser-based Slack automation. Check unreads, navigate channels, search conversations, send messages, and extract data — no API tokens needed.
## Installation
```bash
npx skills add vercel-labs/agent-browser --skill agent-browser
npx skills add vercel-labs/agent-browser --skill dogfood
npx skills add vercel-labs/agent-browser --skill electron
npx skills add vercel-labs/agent-browser --skill slack
```
After installing, your AI agent will automatically activate the right skill when it encounters a matching request.
## agent-browser
The core skill. Teaches agents the full agent-browser API: the navigate-snapshot-interact-re-snapshot workflow, all commands, command chaining, authentication (auth vault and state persistence), sessions, diffing, JavaScript evaluation, annotated screenshots, semantic locators, and configuration.
Example agent interactions:
- "Open example.com and fill out the contact form"
- "Take a screenshot of the dashboard after logging in"
- "Compare staging and production versions of the homepage"
## dogfood
A structured workflow for exploratory testing. The agent opens a target URL, systematically explores the app (navigating pages, testing forms, clicking buttons, checking console errors), and documents every issue it finds with:
- Numbered repro steps
- Step-by-step screenshots
- Repro videos for interactive bugs
- Severity classification
The output is a markdown report in an output directory, ready to hand to the responsible team. Run it with a single prompt like "dogfood vercel.com" or "QA http://localhost:3000 — focus on the billing page".
## electron
Electron apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. This skill teaches agents how to launch or connect to any Electron app, then use the standard snapshot-interact workflow to automate it.
Electron apps are built on Chromium, so they expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. Launch the app with `--remote-debugging-port`, connect, and use the standard snapshot-interact workflow. This is the foundation that the **slack** skill builds on.
## slack
Browser-based Slack automation. Connects to an existing Slack session (via `agent-browser connect 9222`) or opens Slack in a new browser, then uses snapshots and element refs to navigate the UI. Covers checking unreads, navigating channels and DMs, searching conversations, extracting message data, and taking screenshots — all without needing Slack API tokens or bot setup.
## Source
All skill files are in the [`skills/`](https://github.com/vercel-labs/agent-browser/tree/main/skills) directory of the repository.
-120
View File
@@ -1,120 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("snapshots")
# Snapshots
The `snapshot` command returns a compact accessibility tree with refs for element interaction.
## Options
Filter output to reduce size:
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -i -C # Include cursor-interactive elements
agent-browser snapshot -c # Compact (remove empty elements)
agent-browser snapshot -d 3 # Limit depth to 3 levels
agent-browser snapshot -s "#main" # Scope to CSS selector
agent-browser snapshot -i -c -d 5 # Combine options
```
<table>
<thead>
<tr><th>Option</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>-i, --interactive</code></td><td>Only interactive elements (buttons, links, inputs)</td></tr>
<tr><td><code>-C, --cursor</code></td><td>Include cursor-interactive elements (cursor:pointer, onclick, tabindex)</td></tr>
<tr><td><code>-c, --compact</code></td><td>Remove empty structural elements</td></tr>
<tr><td><code>-d, --depth</code></td><td>Limit tree depth</td></tr>
<tr><td><code>-s, --selector</code></td><td>Scope to CSS selector</td></tr>
</tbody>
</table>
## Cursor-interactive elements
Many modern web apps use custom clickable elements (divs, spans) instead of standard buttons or links.
The `-C` flag detects these by looking for:
- `cursor: pointer` CSS style
- `onclick` attribute or handler
- `tabindex` attribute (keyboard focusable)
```bash
agent-browser snapshot -i -C
# Output includes:
# @e1 [button] "Submit"
# @e2 [link] "Learn more"
# Cursor-interactive elements:
# @e3 [clickable] "Menu Item" [cursor:pointer, onclick]
# @e4 [clickable] "Card" [cursor:pointer]
```
## Output format
The default text output is compact and AI-friendly:
```bash
agent-browser snapshot -i
# Output:
# @e1 [heading] "Example Domain" [level=1]
# @e2 [button] "Submit"
# @e3 [input type="email"] placeholder="Email"
# @e4 [link] "Learn more"
```
## Using refs
Refs from the snapshot map directly to commands:
```bash
agent-browser click @e2 # Click the Submit button
agent-browser fill @e3 "a@b.com" # Fill the email input
agent-browser get text @e1 # Get heading text
```
## Ref lifecycle
Refs are invalidated when the page changes. Always re-snapshot after navigation or DOM updates:
```bash
agent-browser click @e4 # Navigates to new page
agent-browser snapshot -i # Get fresh refs
agent-browser click @e1 # Use new refs
```
## Annotated screenshots
For visual context alongside text snapshots, use `screenshot --annotate` to overlay numbered labels on interactive elements. Each label `[N]` maps to ref `@eN`:
```bash
agent-browser screenshot --annotate ./page.png
# -> Screenshot saved to ./page.png
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
# [3] @e3 textbox "Email"
agent-browser click @e2
```
Annotated screenshots also cache refs, so you can interact with elements immediately. This is useful when the text snapshot is insufficient -- unlabeled icons, canvas content, or visual layout verification.
## Best practices
1. Use `-i` to reduce output to actionable elements
2. Re-snapshot after page changes to get updated refs
3. Scope with `-s` for specific page sections
4. Use `-d` to limit depth on complex pages
5. Use `screenshot --annotate` when visual context is needed alongside refs
## JSON output
For programmatic parsing in scripts:
```bash
agent-browser snapshot --json
# {"success":true,"data":{"snapshot":"...","refs":{"e1":{"role":"heading","name":"Title"},...}}}
```
Note: JSON uses more tokens than text output. The default text format is preferred for AI agents.
-232
View File
@@ -1,232 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("streaming")
# Streaming
Stream the browser viewport via WebSocket for live preview or "pair browsing"
where a human can watch and interact alongside an AI agent.
## Enable streaming
Set the `AGENT_BROWSER_STREAM_PORT` environment variable to start
a WebSocket server:
```bash
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
```
The server streams viewport frames and accepts input events (mouse, keyboard, touch).
## WebSocket protocol
Connect to `ws://localhost:9223` to receive frames and send input.
### Frame messages
The server sends frame messages with base64-encoded images:
```json
{
"type": "frame",
"data": "<base64-encoded-jpeg>",
"metadata": {
"deviceWidth": 1280,
"deviceHeight": 720,
"pageScaleFactor": 1,
"offsetTop": 0,
"scrollOffsetX": 0,
"scrollOffsetY": 0
}
}
```
### Status messages
Connection and screencast status:
```json
{
"type": "status",
"connected": true,
"screencasting": true,
"viewportWidth": 1280,
"viewportHeight": 720
}
```
## Input injection
Send input events to control the browser remotely.
### Mouse events
```json
// Click
{
"type": "input_mouse",
"eventType": "mousePressed",
"x": 100,
"y": 200,
"button": "left",
"clickCount": 1
}
// Release
{
"type": "input_mouse",
"eventType": "mouseReleased",
"x": 100,
"y": 200,
"button": "left"
}
// Move
{
"type": "input_mouse",
"eventType": "mouseMoved",
"x": 150,
"y": 250
}
// Scroll
{
"type": "input_mouse",
"eventType": "mouseWheel",
"x": 100,
"y": 200,
"deltaX": 0,
"deltaY": 100
}
```
### Keyboard events
```json
// Key down
{
"type": "input_keyboard",
"eventType": "keyDown",
"key": "Enter",
"code": "Enter"
}
// Key up
{
"type": "input_keyboard",
"eventType": "keyUp",
"key": "Enter",
"code": "Enter"
}
// Type character
{
"type": "input_keyboard",
"eventType": "char",
"text": "a"
}
// With modifiers (1=Alt, 2=Ctrl, 4=Meta, 8=Shift)
{
"type": "input_keyboard",
"eventType": "keyDown",
"key": "c",
"code": "KeyC",
"modifiers": 2
}
```
### Touch events
```json
// Touch start
{
"type": "input_touch",
"eventType": "touchStart",
"touchPoints": [{ "x": 100, "y": 200 }]
}
// Touch move
{
"type": "input_touch",
"eventType": "touchMove",
"touchPoints": [{ "x": 150, "y": 250 }]
}
// Touch end
{
"type": "input_touch",
"eventType": "touchEnd",
"touchPoints": []
}
// Multi-touch (pinch zoom)
{
"type": "input_touch",
"eventType": "touchStart",
"touchPoints": [
{ "x": 100, "y": 200, "id": 0 },
{ "x": 200, "y": 200, "id": 1 }
]
}
```
## Programmatic API
For advanced use, control streaming directly via the TypeScript API:
```typescript
import { BrowserManager } from 'agent-browser-stealth';
const browser = new BrowserManager();
await browser.launch({ headless: true });
await browser.navigate('https://example.com');
// Start screencast with callback
await browser.startScreencast((frame) => {
console.log('Frame:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
// frame.data is base64-encoded image
}, {
format: 'jpeg', // or 'png'
quality: 80, // 0-100, jpeg only
maxWidth: 1280,
maxHeight: 720,
everyNthFrame: 1
});
// Inject mouse event
await browser.injectMouseEvent({
type: 'mousePressed',
x: 100,
y: 200,
button: 'left',
clickCount: 1
});
// Inject keyboard event
await browser.injectKeyboardEvent({
type: 'keyDown',
key: 'Enter',
code: 'Enter'
});
// Inject touch event
await browser.injectTouchEvent({
type: 'touchStart',
touchPoints: [{ x: 100, y: 200 }]
});
// Check if screencasting
console.log('Active:', browser.isScreencasting());
// Stop screencast
await browser.stopScreencast();
```
## Use cases
- **Pair browsing** - Human watches and assists AI agent in real-time
- **Remote preview** - View browser output in a separate UI
- **Recording** - Capture frames for video generation
- **Mobile testing** - Inject touch events for mobile emulation
- **Accessibility testing** - Manual interaction during automated tests
-25
View File
@@ -1,25 +0,0 @@
import { codeToHtml } from "shiki";
import { CopyButton } from "./copy-button";
interface CodeBlockProps {
code: string;
lang?: string;
}
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
const trimmedCode = code.trim();
const html = await codeToHtml(trimmedCode, {
lang,
themes: {
light: "github-light-default",
dark: "github-dark-default",
},
});
return (
<div className="code-block relative group">
<CopyButton code={trimmedCode} />
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
"use client";
import { useState } from "react";
interface CopyButtonProps {
code: string;
}
export function CopyButton({ code }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Optionally, you could set an error state or show a toast notification here
}
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded text-[#666] hover:text-[#999] hover:bg-[#333] opacity-0 group-hover:opacity-100 transition-all"
aria-label="Copy code"
>
{copied ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
-71
View File
@@ -1,71 +0,0 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
export function CopyPageButton() {
const pathname = usePathname();
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
const handleCopy = async () => {
setState("loading");
try {
const response = await fetch(
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
);
if (!response.ok) {
throw new Error("Failed to fetch markdown");
}
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);
setState("copied");
setTimeout(() => setState("idle"), 2000);
} catch {
setState("idle");
}
};
return (
<button
onClick={handleCopy}
disabled={state === "loading"}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
aria-label="Copy page as Markdown"
>
{state === "copied" ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
Copied
</>
) : (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy Page
</>
)}
</button>
);
}

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