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
180 changed files with 37073 additions and 38151 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
+93 -39
View File
@@ -10,12 +10,40 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: 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
@@ -31,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
@@ -127,19 +165,13 @@ jobs:
path: artifacts/${{ matrix.binary }}
retention-days: 7
# Create release PR or publish to npm (with binaries)
release:
name: Release
needs: build-binaries
publish:
name: Publish to npm
needs: [check-release, build-binaries]
runs-on: ubuntu-latest
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
steps:
- name: Checkout Repo
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -153,7 +185,7 @@ jobs:
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download all binary artifacts
@@ -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,31 +233,20 @@ jobs:
echo "Error: $ERRORS binary issues found"
exit 1
fi
echo "All 5 platform binaries present and valid"
echo "All 7 platform binaries present and valid"
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: 'chore: version packages'
commit: 'chore: version packages'
- name: Publish to npm
run: pnpm publish --no-git-checks
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
# Create GitHub release with binaries after npm publish
github-release:
name: Create GitHub Release
needs: release
if: needs.release.outputs.published == 'true'
needs: [check-release, publish]
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
- name: Download all artifacts
uses: actions/download-artifact@v4
@@ -241,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
+8
View File
@@ -0,0 +1,8 @@
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
exit 0
fi
pnpm run clawhub:sync || {
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
}
+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
-253
View File
@@ -1,253 +0,0 @@
# agent-browser
## 0.16.1
### Patch Changes
- c4180c8: Improved Chrome launch reliability by automatically detecting containerized environments (Docker, Podman, Kubernetes) and enabling --no-sandbox when needed. Added support for discovering Playwright-installed Chromium browsers and enhanced error messages with helpful diagnostics when Chrome fails to launch.
## 0.16.0
### Minor Changes
- 05018b3: Added experimental native Rust daemon (`--native` flag, `AGENT_BROWSER_NATIVE=1` env, or `"native": true` in config). The native daemon communicates with Chrome directly via CDP, eliminating Node.js and Playwright dependencies. Supports 150+ commands with full parity to the default Node.js daemon. Includes WebDriver backend for Safari/iOS, CDP protocol codegen, request tracking, frame context management, and comprehensive e2e and parity tests.
## 0.15.3
### Patch Changes
- 62241b5: Fixed Windows compatibility issues including proper handling of extended-length path prefixes from canonicalize(), prevention of MSYS/Git Bash path translation that could mangle arguments, and improved daemon startup reliability. Also added ARM64 Windows support in postinstall shims and expanded CI testing with a full daemon lifecycle test on Windows.
## 0.15.2
### Patch Changes
- 6aea316: Documentation site improvements and internal tooling updates including enhanced code blocks, mobile navigation, and docs chat components. CLI connection and output handling refinements. Skill creator reference documentation and scripts have been reorganized.
## 0.15.1
### Patch Changes
- 7bd8ce9: Added support for chrome:// and chrome-extension:// URLs in navigation and recording commands. These special browser URLs are now preserved as-is instead of having https:// incorrectly prepended.
## 0.15.0
### Minor Changes
- 2e38882: - Added security hardening: authentication vault, content boundary markers, domain allowlist, action policy, action confirmation, and output length limits.
- Added `--download-path` flag (and `AGENT_BROWSER_DOWNLOAD_PATH` env / `downloadPath` config key) to set a default download directory.
- Added `--selector` flag to `scroll` command for scrolling within specific container elements.
## 0.14.0
### 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
+335 -175
View File
@@ -1,51 +1,41 @@
# agent-browser
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback.
Browser automation CLI for AI agents. Fast native Rust CLI.
## Installation
### Global Installation (recommended)
Installs the native Rust binary for maximum performance:
Installs the native Rust binary:
```bash
npm install -g agent-browser
agent-browser install # Download Chromium
agent-browser install # Download Chrome from Chrome for Testing (first time only)
```
This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead.
### Quick Start (no install)
Run directly with `npx` if you want to try it without installing globally:
```bash
npx agent-browser install # Download Chromium (first time only)
npx agent-browser open example.com
```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
### Project Installation (local dependency)
For projects that want to pin the version in `package.json`:
```bash
npm install agent-browser
npx agent-browser install
agent-browser install
```
Then use via `npx` or `package.json` scripts:
```bash
npx agent-browser open example.com
```
Then use via `package.json` scripts or by invoking `agent-browser` directly.
### Homebrew (macOS)
```bash
brew install agent-browser
agent-browser install # Download Chromium
agent-browser install # Download Chrome from Chrome for Testing (first time only)
```
### Cargo (Rust)
```bash
cargo install agent-browser
agent-browser install # Download Chrome from Chrome for Testing (first time only)
```
### From Source
@@ -66,9 +56,23 @@ On Linux, install system dependencies:
```bash
agent-browser install --with-deps
# or manually: npx playwright install-deps chromium
```
### Updating
Upgrade to the latest version:
```bash
agent-browser upgrade
```
Detects your installation method (npm, Homebrew, or Cargo) and runs the appropriate update command automatically.
### Requirements
- **Chrome** - Run `agent-browser install` to download Chrome from [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) (Google's official automation channel). Existing Chrome, Brave, Playwright, and Puppeteer installations are detected automatically. No Playwright or Node.js required for the daemon.
- **Rust** - Only needed when building from source (see From Source above).
## Quick Start
```bash
@@ -115,11 +119,17 @@ agent-browser drag <src> <tgt> # Drag and drop
agent-browser upload <sel> <files> # Upload files
agent-browser screenshot [path] # Take screenshot (--full for full page, saves to a temporary directory if no path)
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser screenshot --screenshot-dir ./shots # Save to custom directory
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
agent-browser pdf <path> # Save as PDF
agent-browser snapshot # Accessibility tree with refs (best for AI)
agent-browser eval <js> # Run JavaScript (-b for base64, --stdin for piped input)
agent-browser connect <port> # Connect to browser via CDP
agent-browser stream enable [--port <port>] # Start runtime WebSocket streaming
agent-browser stream status # Show runtime streaming state and bound port
agent-browser stream disable # Stop runtime WebSocket streaming
agent-browser close # Close browser (aliases: quit, exit)
agent-browser close --all # Close all active sessions
```
### Get Info
@@ -131,6 +141,7 @@ agent-browser get value <sel> # Get input value
agent-browser get attr <sel> <attr> # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get cdp-url # Get CDP WebSocket URL (for DevTools, debugging)
agent-browser get count <sel> # Count matching elements
agent-browser get box <sel> # Get bounding box
agent-browser get styles <sel> # Get computed styles
@@ -164,6 +175,7 @@ agent-browser find nth <n> <sel> <action> [value] # Nth match
**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 text "Sign In" click
@@ -177,14 +189,46 @@ agent-browser find nth 2 "a" text
```bash
agent-browser wait <selector> # Wait for element to be visible
agent-browser wait <ms> # Wait for time (milliseconds)
agent-browser wait --text "Welcome" # Wait for text to appear
agent-browser wait --text "Welcome" # Wait for text to appear (substring match)
agent-browser wait --url "**/dash" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for load state
agent-browser wait --fn "window.ready === true" # Wait for JS condition
# Wait for text/element to disappear
agent-browser wait --fn "!document.body.innerText.includes('Loading...')"
agent-browser wait "#spinner" --state hidden
```
**Load states:** `load`, `domcontentloaded`, `networkidle`
### Batch Execution
Execute multiple commands in a single invocation by piping a JSON array of
string arrays to `batch`. This avoids per-command process startup overhead
when running multi-step workflows.
```bash
# Pipe commands as JSON
echo '[
["open", "https://example.com"],
["snapshot", "-i"],
["click", "@e1"],
["screenshot", "result.png"]
]' | agent-browser batch --json
# Stop on first error
agent-browser batch --bail < commands.json
```
### Clipboard
```bash
agent-browser clipboard read # Read text from clipboard
agent-browser clipboard write "Hello, World!" # Write text to clipboard
agent-browser clipboard copy # Copy current selection (Ctrl+C)
agent-browser clipboard paste # Paste from clipboard (Ctrl+V)
```
### Mouse Control
```bash
@@ -197,7 +241,7 @@ agent-browser mouse wheel <dy> [dx] # Scroll wheel
### Browser Settings
```bash
agent-browser set viewport <w> <h> # Set viewport size
agent-browser set viewport <w> <h> [scale] # Set viewport size (scale for retina, e.g. 2)
agent-browser set device <name> # Emulate device ("iPhone 14")
agent-browser set geo <lat> <lng> # Set geolocation
agent-browser set offline [on|off] # Toggle offline mode
@@ -230,6 +274,12 @@ 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 --filter api # Filter requests
agent-browser network requests --type xhr,fetch # Filter by resource type
agent-browser network requests --method POST # Filter by HTTP method
agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499)
agent-browser network request <requestId> # View full request/response detail
agent-browser network har start # Start HAR recording
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
```
### Tabs & Windows
@@ -254,8 +304,13 @@ agent-browser frame main # Back to main frame
```bash
agent-browser dialog accept [text] # Accept (with optional prompt text)
agent-browser dialog dismiss # Dismiss
agent-browser dialog status # Check if a dialog is currently open
```
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` (or `AGENT_BROWSER_NO_AUTO_DIALOG=1`) to disable automatic handling.
When a JavaScript dialog is pending, all command responses include a `warning` field with the dialog type and message.
### Diff
```bash
@@ -279,10 +334,12 @@ 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 console # View console messages (log, error, warn, info)
agent-browser console --json # JSON output with raw CDP args for programmatic access
agent-browser console --clear # Clear console
agent-browser errors # View page errors (uncaught JavaScript exceptions)
agent-browser errors --clear # Clear errors
agent-browser highlight <sel> # Highlight element
agent-browser inspect # Open Chrome DevTools for the active page
agent-browser state save <path> # Save auth state
agent-browser state load <path> # Load auth state
agent-browser state list # List saved state files
@@ -304,10 +361,52 @@ agent-browser reload # Reload page
### Setup
```bash
agent-browser install # Download Chromium browser
agent-browser install # Download Chrome from Chrome for Testing (Google's official automation channel)
agent-browser install --with-deps # Also install system deps (Linux)
agent-browser upgrade # Upgrade agent-browser to the latest version
```
## Authentication
agent-browser provides multiple ways to persist login sessions so you don't re-authenticate every run.
### Quick summary
| Approach | Best for | Flag / Env |
|----------|----------|------------|
| **Persistent profile** | Full browser state (cookies, IndexedDB, service workers, cache) across restarts | `--profile <path>` / `AGENT_BROWSER_PROFILE` |
| **Session persistence** | Auto-save/restore cookies + localStorage by name | `--session-name <name>` / `AGENT_BROWSER_SESSION_NAME` |
| **Import from your browser** | Grab auth from a Chrome session you already logged into | `--auto-connect` + `state save` |
| **State file** | Load a previously saved state JSON on launch | `--state <path>` / `AGENT_BROWSER_STATE` |
| **Auth vault** | Store credentials locally (encrypted), login by name | `auth save` / `auth login` |
### Import auth from your browser
If you are already logged in to a site in Chrome, you can grab that auth state and reuse it:
```bash
# 1. Launch Chrome with remote debugging enabled
# macOS:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
# Or use --auto-connect to discover an already-running Chrome
# 2. Connect and save the authenticated state
agent-browser --auto-connect state save ./my-auth.json
# 3. Use the saved auth in future sessions
agent-browser --state ./my-auth.json open https://app.example.com/dashboard
# 4. Or use --session-name for automatic persistence
agent-browser --session-name myapp state load ./my-auth.json
# From now on, --session-name myapp auto-saves/restores this state
```
> **Security notes:**
> - `--remote-debugging-port` exposes full browser control on localhost. Any local process can connect. Only use on trusted machines and close Chrome when done.
> - State files contain session tokens in plaintext. Add them to `.gitignore` and delete when no longer needed. For encryption at rest, set `AGENT_BROWSER_ENCRYPTION_KEY` (see [State Encryption](#state-encryption)).
For full details on login flows, OAuth, 2FA, cookie-based auth, and the auth vault, see the [Authentication](docs/src/app/sessions/page.mdx) docs.
## Sessions
Run multiple isolated browser instances:
@@ -332,6 +431,7 @@ agent-browser session
```
Each session has its own:
- Browser instance
- Cookies and storage
- Navigation history
@@ -353,6 +453,7 @@ AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
```
The profile directory stores:
- Cookies and localStorage
- IndexedDB data
- Service workers
@@ -389,33 +490,33 @@ export AGENT_BROWSER_ENCRYPTION_KEY=<64-char-hex-key>
agent-browser --session-name secure open example.com
```
| Variable | Description |
|----------|-------------|
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
| Variable | Description |
| --------------------------------- | -------------------------------------------------- |
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
| `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) |
## Security
agent-browser includes security features for safe AI agent deployments. All features are opt-in -- existing workflows are unaffected until you explicitly enable a feature:
- **Authentication Vault** -- Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github`
- **Authentication Vault** -- Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. `auth login` navigates with `load` and then waits for login form selectors to appear (SPA-friendly, timeout follows the default action timeout). A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github`
- **Content Boundary Markers** -- Wrap page output in delimiters so LLMs can distinguish tool output from untrusted content: `--content-boundaries`
- **Domain Allowlist** -- Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain): `--allowed-domains "example.com,*.example.com"`. Sub-resource requests (scripts, images, fetch) and WebSocket/EventSource connections to non-allowed domains are also blocked. Include any CDN domains your target pages depend on (e.g., `*.cdn.example.com`).
- **Action Policy** -- Gate destructive actions with a static policy file: `--action-policy ./policy.json`
- **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions eval,download`
- **Output Length Limits** -- Prevent context flooding: `--max-output 50000`
| Variable | Description |
|----------|-------------|
| `AGENT_BROWSER_CONTENT_BOUNDARIES` | Wrap page output in boundary markers |
| `AGENT_BROWSER_MAX_OUTPUT` | Max characters for page output |
| `AGENT_BROWSER_ALLOWED_DOMAINS` | Comma-separated allowed domain patterns |
| `AGENT_BROWSER_ACTION_POLICY` | Path to action policy JSON file |
| `AGENT_BROWSER_CONFIRM_ACTIONS` | Action categories requiring confirmation |
| `AGENT_BROWSER_CONFIRM_INTERACTIVE` | Enable interactive confirmation prompts |
| Variable | Description |
| ----------------------------------- | ---------------------------------------- |
| `AGENT_BROWSER_CONTENT_BOUNDARIES` | Wrap page output in boundary markers |
| `AGENT_BROWSER_MAX_OUTPUT` | Max characters for page output |
| `AGENT_BROWSER_ALLOWED_DOMAINS` | Comma-separated allowed domain patterns |
| `AGENT_BROWSER_ACTION_POLICY` | Path to action policy JSON file |
| `AGENT_BROWSER_CONFIRM_ACTIONS` | Action categories requiring confirmation |
| `AGENT_BROWSER_CONFIRM_INTERACTIVE` | Enable interactive confirmation prompts |
See [Security documentation](https://agent-browser.vercel.app/security) for details.
See [Security documentation](https://agent-browser.dev/security) for details.
## Snapshot Options
@@ -424,27 +525,25 @@ The `snapshot` command supports filtering to reduce output size:
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (buttons, inputs, links)
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, etc.)
agent-browser snapshot -c # Compact (remove empty structural 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
```
| Option | Description |
|--------|-------------|
| `-i, --interactive` | Only show interactive elements (buttons, links, inputs) |
| `-C, --cursor` | Include cursor-interactive elements (cursor:pointer, onclick, tabindex) |
| `-c, --compact` | Remove empty structural elements |
| `-d, --depth <n>` | Limit tree depth |
| `-s, --selector <sel>` | Scope to CSS selector |
The `-C` flag is useful for modern web apps that use custom clickable elements (divs, spans) instead of standard buttons/links.
| Option | Description |
| ---------------------- | ----------------------------------------------------------------------- |
| `-i, --interactive` | Only show interactive elements (buttons, links, inputs) |
| `-c, --compact` | Remove empty structural elements |
| `-d, --depth <n>` | Limit tree depth |
| `-s, --selector <sel>` | Scope to CSS selector |
## Annotated Screenshots
The `--annotate` flag overlays numbered labels on interactive elements in the screenshot. Each label `[N]` corresponds to ref `@eN`, so the same refs work for both visual and text-based workflows.
Annotated screenshots are supported on the CDP-backed browser path (Chrome/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`.
```bash
agent-browser screenshot --annotate
# -> Screenshot saved to /tmp/screenshot-2026-02-17T12-00-00-abc123.png
@@ -482,9 +581,11 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `-p, --provider <name>` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) |
| `--device <name>` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) |
| `--json` | JSON output (for agents) |
| `--full, -f` | Full page screenshot |
| `--annotate` | Annotated screenshot with numbered element labels (or `AGENT_BROWSER_ANNOTATE` env) |
| `--headed` | Show browser window (not headless) |
| `--screenshot-dir <path>` | Default screenshot output directory (or `AGENT_BROWSER_SCREENSHOT_DIR` env) |
| `--screenshot-quality <n>` | JPEG quality 0-100 (or `AGENT_BROWSER_SCREENSHOT_QUALITY` env) |
| `--screenshot-format <fmt>` | Screenshot format: `png`, `jpeg` (or `AGENT_BROWSER_SCREENSHOT_FORMAT` env) |
| `--headed` | Show browser window (not headless) (or `AGENT_BROWSER_HEADED` env) |
| `--cdp <port\|url>` | Connect via Chrome DevTools Protocol (port or WebSocket URL) |
| `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) |
| `--color-scheme <scheme>` | Color scheme: `dark`, `light`, `no-preference` (or `AGENT_BROWSER_COLOR_SCHEME` env) |
@@ -495,10 +596,38 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `--action-policy <path>` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) |
| `--confirm-actions <list>` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) |
| `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) |
| `--native` | [Experimental] Use native Rust daemon instead of Node.js (or `AGENT_BROWSER_NATIVE` env) |
| `--engine <name>` | Browser engine: `chrome` (default), `lightpanda` (or `AGENT_BROWSER_ENGINE` env) |
| `--no-auto-dialog` | Disable automatic dismissal of `alert`/`beforeunload` dialogs (or `AGENT_BROWSER_NO_AUTO_DIALOG` env) |
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
| `--debug` | Debug output |
## Observability Dashboard
Monitor agent-browser sessions in real time with a local web dashboard showing a live viewport and command activity feed.
```bash
# Install the dashboard (one time)
agent-browser dashboard install
# Start the dashboard server (runs in background on port 4848)
agent-browser dashboard start
agent-browser dashboard start --port 8080 # Custom port
# All sessions are automatically visible in the dashboard
agent-browser open example.com
# Stop the dashboard
agent-browser dashboard stop
```
The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard.
The dashboard displays:
- **Live viewport** -- real-time JPEG frames from the browser
- **Activity feed** -- chronological command/result stream with timing and expandable details
- **Console output** -- browser console messages (log, warn, error)
- **Session creation** -- create new sessions from the UI with local engines (Chrome, Lightpanda) or cloud providers (AgentCore, Browserbase, Browserless, Browser Use, Kernel)
## Configuration
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
@@ -539,7 +668,7 @@ Auto-discovered config files that are missing are silently ignored. If `--config
## Default Timeout
The default Playwright timeout for standard operations (clicks, waits, fills, etc.) is 25 seconds. This is intentionally below the CLI's 30-second IPC read timeout so that Playwright returns a proper error instead of the CLI timing out with EAGAIN.
The default timeout for standard operations (clicks, waits, fills, etc.) is 25 seconds. This is intentionally below the CLI's 30-second IPC read timeout so that the daemon returns a proper error instead of the CLI timing out with EAGAIN.
Override the default timeout via environment variable:
@@ -548,11 +677,11 @@ Override the default timeout via environment variable:
export AGENT_BROWSER_DEFAULT_TIMEOUT=45000
```
> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before Playwright responds. The CLI retries transient errors automatically, but response times will increase.
> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before the daemon responds. The CLI retries transient errors automatically, but response times will increase.
| Variable | Description |
|----------|-------------|
| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default Playwright timeout in ms (default: 25000) |
| Variable | Description |
| ------------------------------- | ---------------------------------------- |
| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default operation timeout in ms (default: 25000) |
## Selectors
@@ -577,6 +706,7 @@ agent-browser hover @e4 # Hover the link
```
**Why use refs?**
- **Deterministic**: Ref points to exact element from snapshot
- **Fast**: No DOM re-query needed
- **AI-friendly**: Snapshot + ref workflow is optimal for LLMs
@@ -658,6 +788,8 @@ agent-browser open example.com --headed
This opens a visible browser window instead of running headless.
> **Note:** Browser extensions work in both headed and headless mode (Chrome's `--headless=new`).
## Authenticated Sessions
Use `--headers` to set HTTP headers for a specific origin, enabling authentication without login flows:
@@ -675,6 +807,7 @@ agent-browser open other-site.com
```
This is useful for:
- **Skipping login flows** - Authenticate via headers instead of UI
- **Switching users** - Start new sessions with different auth tokens
- **API testing** - Access protected endpoints directly
@@ -696,6 +829,7 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
## Custom Browser Executable
Use a custom browser executable instead of the bundled Chromium. This is useful for:
- **Serverless deployment**: Use lightweight Chromium builds like `@sparticuz/chromium` (~50MB vs ~684MB)
- **System browsers**: Use an existing Chrome/Chromium installation
- **Custom builds**: Use modified browser builds
@@ -710,19 +844,34 @@ agent-browser --executable-path /path/to/chromium open example.com
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
```
### Serverless Example (Vercel/AWS Lambda)
### Serverless (Vercel)
Run agent-browser + Chrome in an ephemeral Vercel Sandbox microVM. No external server needed:
```typescript
import { Sandbox } from "@vercel/sandbox";
const sandbox = await Sandbox.create({ runtime: "node24" });
await sandbox.runCommand("agent-browser", ["open", "https://example.com"]);
const result = await sandbox.runCommand("agent-browser", ["screenshot", "--json"]);
await sandbox.stop();
```
See the [environments example](examples/environments/) for a working demo with a UI and deploy-to-Vercel button.
### Serverless (AWS Lambda)
```typescript
import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser';
import { execSync } from 'child_process';
export async function handler() {
const browser = new BrowserManager();
await browser.launch({
executablePath: await chromium.executablePath(),
headless: true,
});
// ... use browser
const executablePath = await chromium.executablePath();
const result = execSync(
`AGENT_BROWSER_EXECUTABLE_PATH=${executablePath} agent-browser open https://example.com && agent-browser snapshot -i --json`,
{ encoding: 'utf-8' }
);
return JSON.parse(result);
}
```
@@ -741,6 +890,7 @@ agent-browser screenshot report.png
```
The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-files`, `--allow-file-access`) that allow `file://` URLs to:
- Load and render local files
- Access other local files via JavaScript (XHR, fetch)
- Load local resources (images, scripts, stylesheets)
@@ -768,10 +918,12 @@ agent-browser --cdp "wss://your-browser-service.com/cdp?token=..." snapshot
```
The `--cdp` flag accepts either:
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
This enables control of:
- Electron apps
- Chrome/Chromium instances with remote debugging
- WebView2 applications
@@ -791,10 +943,13 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
```
Auto-connect discovers Chrome by:
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
2. Falling back to probing common debugging ports (9222, 9229)
3. If HTTP-based discovery (`/json/version`, `/json/list`) fails, falling back to a direct WebSocket connection
This is useful when:
- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port)
- You want a zero-configuration connection to your existing browser
- You don't want to track which port Chrome is using
@@ -803,21 +958,35 @@ This is useful when:
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
### Streaming
Set the `AGENT_BROWSER_STREAM_PORT` environment variable:
Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `stream status` to see the bound port and connection state:
```bash
agent-browser stream status
```
To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`:
```bash
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
```
This starts a WebSocket server on the specified port that streams the browser viewport and accepts input events.
You can also manage streaming at runtime with `stream enable`, `stream disable`, and `stream status`:
```bash
agent-browser stream enable --port 9223 # Re-enable on a specific port
agent-browser stream disable # Stop streaming for the session
```
The WebSocket server streams the browser viewport and accepts input events.
### WebSocket Protocol
Connect to `ws://localhost:9223` to receive frames and send input:
**Receive frames:**
```json
{
"type": "frame",
@@ -834,6 +1003,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
```
**Send mouse events:**
```json
{
"type": "input_mouse",
@@ -846,6 +1016,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
```
**Send keyboard events:**
```json
{
"type": "input_keyboard",
@@ -856,6 +1027,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
```
**Send touch events:**
```json
{
"type": "input_touch",
@@ -864,107 +1036,26 @@ Connect to `ws://localhost:9223` to receive frames and send input:
}
```
### Programmatic API
For advanced use, control streaming directly via the protocol:
```typescript
import { BrowserManager } from 'agent-browser';
const browser = new BrowserManager();
await browser.launch({ headless: true });
await browser.navigate('https://example.com');
// Start screencast
await browser.startScreencast((frame) => {
// frame.data is base64-encoded image
// frame.metadata contains viewport info
console.log('Frame received:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
}, {
format: 'jpeg',
quality: 80,
maxWidth: 1280,
maxHeight: 720,
});
// Inject mouse events
await browser.injectMouseEvent({
type: 'mousePressed',
x: 100,
y: 200,
button: 'left',
});
// Inject keyboard events
await browser.injectKeyboardEvent({
type: 'keyDown',
key: 'Enter',
code: 'Enter',
});
// Stop when done
await browser.stopScreencast();
```
## Architecture
agent-browser uses a client-daemon architecture:
1. **Rust CLI** (fast native binary) - Parses commands, communicates with daemon
2. **Node.js Daemon** (default) - Manages Playwright browser instance
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
4. **Fallback** - If native binary unavailable, uses Node.js directly
1. **Rust CLI** - Parses commands, communicates with daemon
2. **Rust Daemon** - Pure Rust daemon using direct CDP, no Node.js required
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
The daemon starts automatically on first command and persists between commands for fast subsequent operations. To auto-shutdown the daemon after a period of inactivity, set `AGENT_BROWSER_IDLE_TIMEOUT_MS` (value in milliseconds). When set, the daemon closes the browser and exits after receiving no commands for the specified duration.
**Browser Engine:** Uses Chromium by default. The default Node.js daemon also supports Firefox and WebKit via Playwright. The experimental native daemon speaks Chrome DevTools Protocol (CDP) directly and supports Chromium-based browsers and Safari (via WebDriver).
## Experimental: Native Mode
The native daemon is a pure Rust implementation that communicates with Chrome directly via CDP, eliminating the Node.js and Playwright dependencies. It is currently **experimental** and opt-in.
### Enabling Native Mode
```bash
# Via flag
agent-browser --native open example.com
# Via environment variable (recommended for persistent use)
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
Or add to your config file (`agent-browser.json`):
```json
{"native": true}
```
### What's Different
| | Default (Node.js) | Native (`--native`) |
|---|---|---|
| **Runtime** | Node.js + Playwright | Pure Rust binary |
| **Protocol** | Playwright protocol | Direct CDP / WebDriver |
| **Install size** | Larger (Node.js + npm deps) | Smaller (single binary) |
| **Browser support** | Chromium, Firefox, WebKit | Chromium, Safari (via WebDriver) |
| **Stability** | Stable | Experimental |
### Known Limitations
- Firefox and WebKit are not yet supported (Chromium and Safari only)
- Some Playwright-specific features (tracing format, HAR export) are not available
- The native daemon and Node.js daemon share the same session socket, so you cannot run both simultaneously for the same session. Use `agent-browser close` before switching modes.
**Browser Engine:** Uses Chrome (from Chrome for Testing) by default. The `--engine` flag selects between `chrome` and `lightpanda`. Supported browsers: Chromium/Chrome (via CDP) and Safari (via WebDriver for iOS).
## Platforms
| Platform | Binary | Fallback |
|----------|--------|----------|
| macOS ARM64 | Native Rust | Node.js |
| macOS x64 | Native Rust | Node.js |
| Linux ARM64 | Native Rust | Node.js |
| Linux x64 | Native Rust | Node.js |
| Windows x64 | Native Rust | Node.js |
| Platform | Binary |
| ----------- | ----------- |
| macOS ARM64 | Native Rust |
| macOS x64 | Native Rust |
| Linux ARM64 | Native Rust |
| Linux x64 | Native Rust |
| Windows x64 | Native Rust |
## Usage with AI Agents
@@ -1008,6 +1099,7 @@ For more consistent results, add to your project or global instructions file:
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
@@ -1059,11 +1151,11 @@ export AGENT_BROWSER_IOS_DEVICE="iPhone 16 Pro"
agent-browser open https://example.com
```
| Variable | Description |
|----------|-------------|
| `AGENT_BROWSER_PROVIDER` | Set to `ios` to enable iOS mode |
| Variable | Description |
| -------------------------- | ----------------------------------------------- |
| `AGENT_BROWSER_PROVIDER` | Set to `ios` to enable iOS mode |
| `AGENT_BROWSER_IOS_DEVICE` | Device name (e.g., "iPhone 16 Pro", "iPad Pro") |
| `AGENT_BROWSER_IOS_UDID` | Device UDID (alternative to device name) |
| `AGENT_BROWSER_IOS_UDID` | Device UDID (alternative to device name) |
**Supported devices:** All iOS Simulators available in Xcode (iPhones, iPads), plus real iOS devices.
@@ -1074,6 +1166,7 @@ agent-browser open https://example.com
Appium also supports real iOS devices connected via USB. This requires additional one-time setup:
**1. Get your device UDID:**
```bash
xcrun xctrace list devices
# or
@@ -1081,6 +1174,7 @@ system_profiler SPUSBDataType | grep -A 5 "iPhone\|iPad"
```
**2. Sign WebDriverAgent (one-time):**
```bash
# Open the WebDriverAgent Xcode project
cd ~/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent
@@ -1088,12 +1182,14 @@ open WebDriverAgent.xcodeproj
```
In Xcode:
- Select the `WebDriverAgentRunner` target
- Go to Signing & Capabilities
- Select your Team (requires Apple Developer account, free tier works)
- Let Xcode manage signing automatically
**3. Use with agent-browser:**
```bash
# Connect device via USB, then:
agent-browser -p ios --device "<DEVICE_UDID>" open https://example.com
@@ -1103,11 +1199,44 @@ 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)
- Device must be unlocked and connected via USB
- Slightly slower initial connection than simulator
- Tests against real Safari performance and behavior
### Browserless
[Browserless](https://browserless.io) provides cloud browser infrastructure with a Sessions API. Use it when running agent-browser in environments where a local browser isn't available.
To enable Browserless, use the `-p` flag:
```bash
export BROWSERLESS_API_KEY="your-api-token"
agent-browser -p browserless open https://example.com
```
Or use environment variables for CI/scripts:
```bash
export AGENT_BROWSER_PROVIDER=browserless
export BROWSERLESS_API_KEY="your-api-token"
agent-browser open https://example.com
```
Optional configuration via environment variables:
| Variable | Description | Default |
| -------------------------- | ------------------------------------------------ | --------------------------------------- |
| `BROWSERLESS_API_URL` | Base API URL (for custom regions or self-hosted) | `https://production-sfo.browserless.io` |
| `BROWSERLESS_BROWSER_TYPE` | Type of browser to use (chromium or chrome) | chromium |
| `BROWSERLESS_TTL` | Session TTL in milliseconds | `300000` |
| `BROWSERLESS_STEALTH` | Enable stealth mode (`true`/`false`) | `true` |
When enabled, agent-browser connects to a Browserless cloud session instead of launching a local browser. All commands work identically.
Get your API token from the [Browserless Dashboard](https://browserless.io).
### Browserbase
[Browserbase](https://browserbase.com) provides remote browser infrastructure to make deployment of agentic browsing agents easy. Use it when running the agent-browser CLI in an environment where a local browser isn't feasible.
@@ -1116,7 +1245,6 @@ To enable Browserbase, use the `-p` flag:
```bash
export BROWSERBASE_API_KEY="your-api-key"
export BROWSERBASE_PROJECT_ID="your-project-id"
agent-browser -p browserbase open https://example.com
```
@@ -1125,13 +1253,12 @@ Or use environment variables for CI/scripts:
```bash
export AGENT_BROWSER_PROVIDER=browserbase
export BROWSERBASE_API_KEY="your-api-key"
export BROWSERBASE_PROJECT_ID="your-project-id"
agent-browser open https://example.com
```
When enabled, agent-browser connects to a Browserbase session instead of launching a local browser. All commands work identically.
Get your API key and project ID from the [Browserbase Dashboard](https://browserbase.com/overview).
Get your API key from the [Browserbase Dashboard](https://browserbase.com/overview).
### Browser Use
@@ -1177,12 +1304,12 @@ agent-browser open https://example.com
Optional configuration via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `KERNEL_HEADLESS` | Run browser in headless mode (`true`/`false`) | `false` |
| `KERNEL_STEALTH` | Enable stealth mode to avoid bot detection (`true`/`false`) | `true` |
| `KERNEL_TIMEOUT_SECONDS` | Session timeout in seconds | `300` |
| `KERNEL_PROFILE_NAME` | Browser profile name for persistent cookies/logins (created if it doesn't exist) | (none) |
| Variable | Description | Default |
| ------------------------ | -------------------------------------------------------------------------------- | ------- |
| `KERNEL_HEADLESS` | Run browser in headless mode (`true`/`false`) | `false` |
| `KERNEL_STEALTH` | Enable stealth mode to avoid bot detection (`true`/`false`) | `true` |
| `KERNEL_TIMEOUT_SECONDS` | Session timeout in seconds | `300` |
| `KERNEL_PROFILE_NAME` | Browser profile name for persistent cookies/logins (created if it doesn't exist) | (none) |
When enabled, agent-browser connects to a Kernel cloud session instead of launching a local browser. All commands work identically.
@@ -1190,6 +1317,39 @@ When enabled, agent-browser connects to a Kernel cloud session instead of launch
Get your API key from the [Kernel Dashboard](https://dashboard.onkernel.com).
### AgentCore
[AWS Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/) provides cloud browser sessions with SigV4 authentication.
To enable AgentCore, use the `-p` flag:
```bash
agent-browser -p agentcore open https://example.com
```
Or use environment variables for CI/scripts:
```bash
export AGENT_BROWSER_PROVIDER=agentcore
agent-browser open https://example.com
```
Credentials are automatically resolved from environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) or the AWS CLI (`aws configure export-credentials`), which supports SSO, profiles, and IAM roles.
Optional configuration via environment variables:
| Variable | Description | Default |
| -------------------------- | -------------------------------------------------------------------- | ---------------- |
| `AGENTCORE_REGION` | AWS region for the AgentCore endpoint | `us-east-1` |
| `AGENTCORE_BROWSER_ID` | Browser identifier | `aws.browser.v1` |
| `AGENTCORE_PROFILE_ID` | Browser profile for persistent state (cookies, localStorage) | (none) |
| `AGENTCORE_SESSION_TIMEOUT`| Session timeout in seconds | `3600` |
| `AWS_PROFILE` | AWS CLI profile for credential resolution | `default` |
**Browser profiles:** When `AGENTCORE_PROFILE_ID` is set, browser state (cookies, localStorage) is persisted across sessions automatically.
When enabled, agent-browser connects to an AgentCore cloud browser session instead of launching a local browser. All commands work identically.
## License
Apache-2.0
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';
+235 -3
View File
@@ -44,27 +44,35 @@ dependencies = [
]
[[package]]
name = "agent-browser"
version = "0.16.1"
name = "agent-browser-stealth"
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]]
@@ -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"
@@ -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"
@@ -1172,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"
@@ -1294,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"
@@ -1414,7 +1512,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -1572,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"
@@ -1922,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"
@@ -2090,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"
@@ -2136,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"
@@ -2346,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"
@@ -2777,12 +2977,44 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
+20 -3
View File
@@ -1,17 +1,27 @@
[package]
name = "agent-browser"
version = "0.16.1"
name = "agent-browser-stealth"
version = "0.24.0-fork.1"
edition = "2021"
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"
[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"
@@ -21,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()) {
+587 -65
View File
@@ -112,7 +112,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
format!("https://{}", url)
};
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin)
if flags.provider.is_some() {
nav_cmd["waitUntil"] = json!("none");
}
if let Some(ref headers_json) = flags.headers {
let headers =
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
@@ -137,7 +139,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Core Actions ===
"click" => {
let new_tab = rest.iter().any(|arg| *arg == "--new-tab");
let new_tab = rest.contains(&"--new-tab");
let sel = rest
.iter()
.find(|arg| **arg != "--new-tab")
@@ -391,7 +393,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
return Ok(json!({ "id": id, "action": "waitforfunction", "expression": expr }));
}
// Check for --text flag: wait --text "Welcome"
// Check for --text flag: wait --text "Welcome" [--timeout ms]
if let Some(idx) = rest.iter().position(|&s| s == "--text" || s == "-t") {
let text = rest
.get(idx + 1)
@@ -399,10 +401,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
context: "wait --text".to_string(),
usage: "wait --text <text>",
})?;
// Use getByText locator to wait for text to appear
return Ok(
json!({ "id": id, "action": "wait", "selector": format!("text={}", text) }),
);
let mut cmd = json!({ "id": id, "action": "wait", "text": text });
if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") {
if let Some(Ok(ms)) = rest.get(t_idx + 1).map(|s| s.parse::<u64>()) {
cmd["timeout"] = json!(ms);
}
}
return Ok(cmd);
}
// Check for --download flag: wait --download [path] [--timeout ms]
@@ -446,10 +451,22 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Screenshot/PDF ===
"screenshot" => {
// screenshot [selector] [path]
// screenshot [selector] [path] [--full/-f]
// selector: @ref or CSS selector
// path: file path (contains / or . or ends with known extension)
let (selector, path) = match (rest.first(), rest.get(1)) {
let mut full_page = false;
let positional: Vec<&str> = rest
.iter()
.filter(|arg| match **arg {
"--full" | "-f" => {
full_page = true;
false
}
_ => true,
})
.copied()
.collect();
let (selector, path) = match (positional.first(), positional.get(1)) {
(Some(first), Some(second)) => {
// Two args: first is selector, second is path
(Some(*first), Some(*second))
@@ -474,9 +491,27 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
_ => (None, None),
};
Ok(
json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": flags.full, "annotate": flags.annotate }),
)
let mut cmd = json!({
"id": id, "action": "screenshot",
"path": path, "selector": selector,
"fullPage": full_page, "annotate": flags.annotate
});
if let Some(ref fmt) = flags.screenshot_format {
cmd["format"] = json!(fmt);
}
if let Some(q) = flags.screenshot_quality {
cmd["quality"] = json!(q);
if flags.screenshot_format.as_deref() != Some("jpeg") {
eprintln!(
"{} --screenshot-quality is ignored for PNG; use --screenshot-format jpeg",
color::warning_indicator()
);
}
}
if let Some(ref dir) = flags.screenshot_dir {
cmd["screenshotDir"] = json!(dir);
}
Ok(cmd)
}
"pdf" => {
let path = rest.first().ok_or_else(|| ParseError::MissingArguments {
@@ -500,6 +535,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
obj.insert("compact".to_string(), json!(true));
}
"-C" | "--cursor" => {
// deprecated, cursor-interactive elements are referred by default now
obj.insert("cursor".to_string(), json!(true));
}
"-d" | "--depth" => {
@@ -568,6 +604,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
// === Inspect ===
"inspect" => Ok(json!({ "id": id, "action": "inspect" })),
// === Authentication Vault ===
"auth" => {
let sub = rest.first().map(|s| s.as_ref());
@@ -588,7 +627,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let mut j = 2;
while j < rest.len() {
match rest[j].as_ref() {
match rest[j] {
"--url" => {
url = rest.get(j + 1).cloned();
j += 1;
@@ -759,6 +798,62 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
// === Runtime stream control ===
"stream" => match rest.first().copied() {
Some("enable") => {
let mut cmd = json!({ "id": id, "action": "stream_enable" });
let mut i = 1;
while i < rest.len() {
match rest[i] {
"--port" => {
let value =
rest.get(i + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: "stream enable --port".to_string(),
usage: "stream enable [--port <port>]",
})?;
let port =
value.parse::<u32>().map_err(|_| ParseError::InvalidValue {
message: format!(
"Invalid port: '{}' is not a valid integer",
value
),
usage: "stream enable [--port <port>]",
})?;
if port > u16::MAX as u32 {
return Err(ParseError::InvalidValue {
message: format!(
"Invalid port: {} is out of range (valid range: 0-65535)",
port
),
usage: "stream enable [--port <port>]",
});
}
cmd["port"] = json!(port);
i += 2;
}
flag => {
return Err(ParseError::InvalidValue {
message: format!("Unknown flag for stream enable: {}", flag),
usage: "stream enable [--port <port>]",
});
}
}
}
Ok(cmd)
}
Some("disable") => Ok(json!({ "id": id, "action": "stream_disable" })),
Some("status") => Ok(json!({ "id": id, "action": "stream_status" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: &["enable", "disable", "status"],
}),
None => Err(ParseError::MissingArguments {
context: "stream".to_string(),
usage: "stream <enable|disable|status>",
}),
},
// === Get ===
"get" => parse_get(&rest, &id),
@@ -950,7 +1045,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Dialog ===
"dialog" => {
const VALID: &[&str] = &["accept", "dismiss"];
const VALID: &[&str] = &["accept", "dismiss", "status"];
match rest.first().copied() {
Some("accept") => {
let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" });
@@ -959,13 +1054,21 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
Ok(cmd)
}
Some("dismiss") => {
let mut cmd = json!({ "id": id, "action": "dialog", "response": "dismiss" });
if let Some(prompt_text) = rest.get(1) {
cmd["promptText"] = json!(prompt_text);
}
Ok(cmd)
}
Some("status") => Ok(json!({ "id": id, "action": "dialog", "response": "status" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "dialog".to_string(),
usage: "dialog <accept|dismiss> [text]",
usage: "dialog <accept|dismiss|status> [text]",
}),
}
}
@@ -1030,7 +1133,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
// === Recording (Playwright native video recording) ===
// === Recording (browser video recording) ===
"record" => {
const VALID: &[&str] = &["start", "stop", "restart"];
match rest.first().copied() {
@@ -1099,6 +1202,27 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
Ok(json!({ "id": id, "action": "highlight", "selector": sel }))
}
// === Clipboard ===
"clipboard" => match rest.first().copied() {
Some("read") | None => {
Ok(json!({ "id": id, "action": "clipboard", "operation": "read" }))
}
Some("write") => {
rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "clipboard write".to_string(),
usage: "clipboard write <text>",
})?;
let text = rest[1..].join(" ");
Ok(json!({ "id": id, "action": "clipboard", "operation": "write", "text": text }))
}
Some("copy") => Ok(json!({ "id": id, "action": "clipboard", "operation": "copy" })),
Some("paste") => Ok(json!({ "id": id, "action": "clipboard", "operation": "paste" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: &["read", "write", "copy", "paste"],
}),
},
// === State ===
"state" => {
const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"];
@@ -1158,7 +1282,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
context: "state show".to_string(),
usage: "state show <filename>",
})?;
Ok(json!({ "id": id, "action": "state_show", "filename": filename }))
Ok(json!({ "id": id, "action": "state_show", "path": filename }))
}
Some("clean") => {
let mut days: Option<i64> = None;
@@ -1263,7 +1387,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
"diff" => parse_diff(&rest, &id, flags),
"diff" => parse_diff(&rest, &id),
// === Batch ===
"batch" => {
let bail = rest.contains(&"--bail");
Ok(json!({ "id": id, "action": "batch", "bail": bail }))
}
_ => Err(ParseError::UnknownCommand {
command: cmd.to_string(),
@@ -1271,7 +1401,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseError> {
fn parse_diff(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["snapshot", "screenshot", "url"];
match rest.first().copied() {
@@ -1416,27 +1546,24 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
});
}
}
"--full" => {
"--full" | "-f" => {
obj.insert("fullPage".to_string(), json!(true));
}
other if other.starts_with('-') => {
return Err(ParseError::InvalidValue {
message: format!("Unknown flag: {}", other),
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full]",
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full/-f]",
});
}
other => {
return Err(ParseError::InvalidValue {
message: format!("Unexpected argument: {}", other),
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full]",
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full/-f]",
});
}
}
i += 1;
}
if flags.full {
obj.insert("fullPage".to_string(), json!(true));
}
if !obj.contains_key("baseline") {
return Err(ParseError::MissingArguments {
context: "diff screenshot".to_string(),
@@ -1467,7 +1594,7 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
"--screenshot" => {
obj.insert("screenshot".to_string(), json!(true));
}
"--full" => {
"--full" | "-f" => {
obj.insert("fullPage".to_string(), json!(true));
}
"--wait-until" => {
@@ -1522,21 +1649,18 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
other if other.starts_with('-') => {
return Err(ParseError::InvalidValue {
message: format!("Unknown flag: {}", other),
usage: "diff url <url1> <url2> [--screenshot] [--full] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
usage: "diff url <url1> <url2> [--screenshot] [--full/-f] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
});
}
other => {
return Err(ParseError::InvalidValue {
message: format!("Unexpected argument: {}", other),
usage: "diff url <url1> <url2> [--screenshot] [--full] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
usage: "diff url <url1> <url2> [--screenshot] [--full/-f] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
});
}
}
i += 1;
}
if flags.full {
obj.insert("fullPage".to_string(), json!(true));
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
@@ -1552,7 +1676,7 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &[
"text", "html", "value", "attr", "url", "title", "count", "box", "styles",
"text", "html", "value", "attr", "url", "title", "count", "box", "styles", "cdp-url",
];
match rest.first().copied() {
@@ -1589,6 +1713,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Ok(json!({ "id": id, "action": "getattribute", "selector": sel, "attribute": attr }))
}
Some("url") => Ok(json!({ "id": id, "action": "url" })),
Some("cdp-url") => Ok(json!({ "id": id, "action": "cdp_url" })),
Some("title") => Ok(json!({ "id": id, "action": "title" })),
Some("count") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -1617,7 +1742,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}),
None => Err(ParseError::MissingArguments {
context: "get".to_string(),
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
usage: "get <text|html|value|attr|url|title|count|box|styles|cdp-url> [args...]",
}),
}
}
@@ -1677,10 +1802,6 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
usage: "find <locator> <value> [action] [text]",
})?;
let name_idx = rest.iter().position(|&s| s == "--name");
let name = name_idx.and_then(|i| rest.get(i + 1).copied());
let exact = rest.contains(&"--exact");
match *locator {
"role" | "text" | "label" | "placeholder" | "alt" | "title" | "testid" | "first"
| "last" => {
@@ -1700,10 +1821,41 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
},
})?;
let subaction = rest.get(2).unwrap_or(&"click");
let fill_value = if rest.len() > 3 {
Some(rest[3..].join(" "))
} else {
let mut name: Option<&str> = None;
let mut exact = false;
let mut fill_parts: Vec<&str> = Vec::new();
if rest.len() > 3 {
let mut i = 3;
while i < rest.len() {
match rest[i] {
"--exact" => {
exact = true;
i += 1;
}
"--name" => {
let n =
rest.get(i + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: format!("find {}", locator),
usage:
"find role <role> [action] [--name <name>] [--exact]",
})?;
name = Some(*n);
i += 2;
}
token => {
fill_parts.push(token);
i += 1;
}
}
}
}
let fill_value = if fill_parts.is_empty() {
None
} else {
Some(fill_parts.join(" "))
};
match *locator {
@@ -1864,25 +2016,35 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Some("viewport") => {
let w_str = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
usage: "set viewport <width> <height> [scale]",
})?;
let h_str = rest.get(2).ok_or_else(|| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
usage: "set viewport <width> <height> [scale]",
})?;
let w = w_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
usage: "set viewport <width> <height> [scale]",
})?;
let h = h_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
usage: "set viewport <width> <height> [scale]",
})?;
Ok(json!({ "id": id, "action": "viewport", "width": w, "height": h }))
let mut cmd = json!({ "id": id, "action": "viewport", "width": w, "height": h });
if let Some(scale_str) = rest.get(3) {
let scale = scale_str
.parse::<f64>()
.map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height> [scale]",
})?;
cmd["deviceScaleFactor"] = json!(scale);
}
Ok(cmd)
}
Some("device") => {
let dev = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -1973,8 +2135,9 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
}
/// Parse network interception, request inspection, and HAR recording commands.
fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["route", "unroute", "requests"];
const VALID: &[&str] = &["route", "unroute", "requests", "request", "har"];
match rest.first().copied() {
Some("route") => {
@@ -1998,19 +2161,62 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
let clear = rest.contains(&"--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).copied());
let type_idx = rest.iter().position(|&s| s == "--type");
let rtype = type_idx.and_then(|i| rest.get(i + 1).copied());
let method_idx = rest.iter().position(|&s| s == "--method");
let method = method_idx.and_then(|i| rest.get(i + 1).copied());
let status_idx = rest.iter().position(|&s| s == "--status");
let status = status_idx.and_then(|i| rest.get(i + 1).copied());
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
if let Some(f) = filter {
cmd["filter"] = json!(f);
}
if let Some(t) = rtype {
cmd["type"] = json!(t);
}
if let Some(m) = method {
cmd["method"] = json!(m);
}
if let Some(s) = status {
cmd["status"] = json!(s);
}
Ok(cmd)
}
Some("request") => {
let request_id = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "network request".to_string(),
usage: "network request <requestId>",
})?;
Ok(json!({ "id": id, "action": "request_detail", "requestId": request_id }))
}
Some("har") => {
const HAR_VALID: &[&str] = &["start", "stop"];
match rest.get(1).copied() {
Some("start") => Ok(json!({ "id": id, "action": "har_start" })),
Some("stop") => {
let mut cmd = json!({ "id": id, "action": "har_stop" });
if let Some(path) = rest.get(2) {
cmd["path"] = json!(path);
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: HAR_VALID,
}),
None => Err(ParseError::MissingArguments {
context: "network har".to_string(),
usage: "network har <start|stop> [path]",
}),
}
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "network".to_string(),
usage: "network <route|unroute|requests> [args...]",
usage: "network <route|unroute|requests|request|har> [args...]",
}),
}
}
@@ -2021,10 +2227,14 @@ fn parse_storage(rest: &[&str], id: &str) -> Result<Value, ParseError> {
match rest.first().copied() {
Some("local") | Some("session") => {
let storage_type = rest.first().unwrap();
let op = rest.get(1).unwrap_or(&"get");
let key = rest.get(2);
let value = rest.get(3);
match *op {
let (op, key, value) = match rest.get(1) {
Some(&"get") => ("get", rest.get(2), rest.get(3)),
Some(&"set") => ("set", rest.get(2), rest.get(3)),
Some(&"clear") => ("clear", rest.get(2), rest.get(3)),
Some(_) => ("get", rest.get(1), rest.get(2)),
None => ("get", None, None),
};
match op {
"set" => {
let k = key.ok_or_else(|| ParseError::MissingArguments {
context: format!("storage {} set", storage_type),
@@ -2070,7 +2280,6 @@ mod tests {
Flags {
session: "test".to_string(),
json: false,
full: false,
headed: false,
debug: false,
headers: None,
@@ -2100,6 +2309,7 @@ mod tests {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_headed: false,
annotate: false,
color_scheme: None,
download_path: None,
@@ -2109,7 +2319,12 @@ mod tests {
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
native: false,
engine: None,
screenshot_dir: None,
screenshot_quality: None,
screenshot_format: None,
idle_timeout: None,
no_auto_dialog: false,
}
}
@@ -2296,6 +2511,14 @@ mod tests {
assert_eq!(cmd["key"], "mykey");
}
#[test]
fn test_storage_local_get_implicit_key() {
let cmd = parse_command(&args("storage local mykey"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "storage_get");
assert_eq!(cmd["type"], "local");
assert_eq!(cmd["key"], "mykey");
}
#[test]
fn test_storage_session_get() {
let cmd = parse_command(&args("storage session"), &default_flags()).unwrap();
@@ -2303,6 +2526,14 @@ mod tests {
assert_eq!(cmd["type"], "session");
}
#[test]
fn test_storage_session_get_implicit_key() {
let cmd = parse_command(&args("storage session mykey"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "storage_get");
assert_eq!(cmd["type"], "session");
assert_eq!(cmd["key"], "mykey");
}
#[test]
fn test_storage_local_set() {
let cmd =
@@ -2565,6 +2796,82 @@ mod tests {
assert_eq!(cmd["action"], "tab_close");
}
// === Network ===
#[test]
fn test_network_har_start() {
let cmd = parse_command(&args("network har start"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "har_start");
}
#[test]
fn test_network_har_stop_with_path() {
let cmd = parse_command(&args("network har stop ./capture.har"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "har_stop");
assert_eq!(cmd["path"], "./capture.har");
}
#[test]
fn test_network_har_stop_without_path() {
let cmd = parse_command(&args("network har stop"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "har_stop");
assert!(cmd.get("path").is_none());
}
#[test]
fn test_network_har_requires_subcommand() {
let result = parse_command(&args("network har"), &default_flags());
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
}
#[test]
fn test_network_requests_type_filter() {
let cmd =
parse_command(&args("network requests --type xhr,fetch"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "requests");
assert_eq!(cmd["type"], "xhr,fetch");
}
#[test]
fn test_network_requests_method_filter() {
let cmd = parse_command(&args("network requests --method POST"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "requests");
assert_eq!(cmd["method"], "POST");
}
#[test]
fn test_network_requests_status_filter() {
let cmd = parse_command(&args("network requests --status 2xx"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "requests");
assert_eq!(cmd["status"], "2xx");
}
#[test]
fn test_network_requests_combined_filters() {
let cmd = parse_command(
&args("network requests --filter api --type xhr --method GET --status 200"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["filter"], "api");
assert_eq!(cmd["type"], "xhr");
assert_eq!(cmd["method"], "GET");
assert_eq!(cmd["status"], "200");
}
#[test]
fn test_network_request_detail() {
let cmd = parse_command(&args("network request 1234.5"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "request_detail");
assert_eq!(cmd["requestId"], "1234.5");
}
#[test]
fn test_network_request_detail_requires_id() {
let result = parse_command(&args("network request"), &default_flags());
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
}
// === Screenshot ===
#[test]
@@ -2584,9 +2891,14 @@ mod tests {
#[test]
fn test_screenshot_full_page() {
let mut flags = default_flags();
flags.full = true;
let cmd = parse_command(&args("screenshot"), &flags).unwrap();
let cmd = parse_command(&args("screenshot --full"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["fullPage"], true);
}
#[test]
fn test_screenshot_full_page_shorthand() {
let cmd = parse_command(&args("screenshot -f"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["fullPage"], true);
}
@@ -2726,7 +3038,78 @@ mod tests {
fn test_wait_text() {
let cmd = parse_command(&args("wait --text Welcome"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "wait");
assert_eq!(cmd["selector"], "text=Welcome");
assert_eq!(cmd["text"], "Welcome");
assert!(cmd.get("timeout").is_none());
}
#[test]
fn test_wait_text_with_timeout() {
let cmd = parse_command(
&args("wait --text Welcome --timeout 5000"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "wait");
assert_eq!(cmd["text"], "Welcome");
assert_eq!(cmd["timeout"], 5000);
}
// === Clipboard Tests ===
#[test]
fn test_clipboard_read_default() {
let cmd = parse_command(&args("clipboard"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "clipboard");
assert_eq!(cmd["operation"], "read");
}
#[test]
fn test_clipboard_read_explicit() {
let cmd = parse_command(&args("clipboard read"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "clipboard");
assert_eq!(cmd["operation"], "read");
}
#[test]
fn test_clipboard_write() {
let cmd = parse_command(&args("clipboard write hello"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "clipboard");
assert_eq!(cmd["operation"], "write");
assert_eq!(cmd["text"], "hello");
}
#[test]
fn test_clipboard_write_multi_word() {
let cmd = parse_command(&args("clipboard write hello world"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "clipboard");
assert_eq!(cmd["operation"], "write");
assert_eq!(cmd["text"], "hello world");
}
#[test]
fn test_clipboard_copy() {
let cmd = parse_command(&args("clipboard copy"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "clipboard");
assert_eq!(cmd["operation"], "copy");
}
#[test]
fn test_clipboard_paste() {
let cmd = parse_command(&args("clipboard paste"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "clipboard");
assert_eq!(cmd["operation"], "paste");
}
#[test]
fn test_clipboard_write_missing_text() {
let result = parse_command(&args("clipboard write"), &default_flags());
assert!(result.is_err());
}
#[test]
fn test_clipboard_unknown_subcommand() {
let result = parse_command(&args("clipboard clear"), &default_flags());
assert!(result.is_err());
}
// === Unknown command ===
@@ -3036,6 +3419,45 @@ mod tests {
assert_eq!(cmd["reducedMotion"], "reduce");
}
#[test]
fn test_set_viewport() {
let cmd = parse_command(&args("set viewport 1920 1080"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "viewport");
assert_eq!(cmd["width"], 1920);
assert_eq!(cmd["height"], 1080);
assert!(cmd.get("deviceScaleFactor").is_none());
}
#[test]
fn test_set_viewport_with_scale() {
let cmd = parse_command(&args("set viewport 1920 1080 2"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "viewport");
assert_eq!(cmd["width"], 1920);
assert_eq!(cmd["height"], 1080);
assert_eq!(cmd["deviceScaleFactor"], 2.0);
}
#[test]
fn test_set_viewport_with_fractional_scale() {
let cmd = parse_command(&args("set viewport 375 812 3"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "viewport");
assert_eq!(cmd["width"], 375);
assert_eq!(cmd["height"], 812);
assert_eq!(cmd["deviceScaleFactor"], 3.0);
}
#[test]
fn test_set_viewport_missing_height() {
let result = parse_command(&args("set viewport 1920"), &default_flags());
assert!(result.is_err());
}
#[test]
fn test_set_viewport_invalid_scale() {
let result = parse_command(&args("set viewport 1920 1080 abc"), &default_flags());
assert!(result.is_err());
}
#[test]
fn test_find_first_no_value() {
let cmd = parse_command(&args("find first a click"), &default_flags()).unwrap();
@@ -3060,6 +3482,21 @@ mod tests {
assert!(cmd.get("value").is_none());
}
#[test]
fn test_find_role_fill_does_not_include_flags_in_value() {
let cmd = parse_command(
&args("find role textbox fill hello --name username --exact"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "getbyrole");
assert_eq!(cmd["role"], "textbox");
assert_eq!(cmd["subaction"], "fill");
assert_eq!(cmd["name"], "username");
assert_eq!(cmd["exact"], true);
assert_eq!(cmd["value"], "hello");
}
// === Download Tests ===
#[test]
@@ -3239,6 +3676,46 @@ mod tests {
assert_eq!(cmd["cdpPort"], 1);
}
// === Runtime stream control tests ===
#[test]
fn test_stream_enable_auto_port() {
let cmd = parse_command(&args("stream enable"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "stream_enable");
assert!(cmd.get("port").is_none());
}
#[test]
fn test_stream_enable_with_port() {
let cmd = parse_command(&args("stream enable --port 9223"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "stream_enable");
assert_eq!(cmd["port"], 9223);
}
#[test]
fn test_stream_status() {
let cmd = parse_command(&args("stream status"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "stream_status");
}
#[test]
fn test_stream_disable() {
let cmd = parse_command(&args("stream disable"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "stream_disable");
}
#[test]
fn test_stream_enable_invalid_port() {
let result = parse_command(&args("stream enable --port abc"), &default_flags());
assert!(matches!(result, Err(ParseError::InvalidValue { .. })));
}
#[test]
fn test_stream_missing_subcommand() {
let result = parse_command(&args("stream"), &default_flags());
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
}
// === Trace Tests ===
#[test]
@@ -3344,10 +3821,23 @@ mod tests {
}
#[test]
fn test_diff_screenshot_global_full_flag() {
let mut flags = default_flags();
flags.full = true;
let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
fn test_diff_screenshot_command_full_flag() {
let cmd = parse_command(
&args("diff screenshot --baseline b.png --full"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_screenshot");
assert_eq!(cmd["fullPage"], true);
}
#[test]
fn test_diff_screenshot_command_full_flag_shorthand() {
let cmd = parse_command(
&args("diff screenshot --baseline b.png -f"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_screenshot");
assert_eq!(cmd["fullPage"], true);
}
@@ -3388,10 +3878,12 @@ mod tests {
}
#[test]
fn test_diff_url_global_full_flag() {
let mut flags = default_flags();
flags.full = true;
let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
fn test_diff_url_command_full_flag() {
let cmd = parse_command(
&args("diff url https://a.com https://b.com --full"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["fullPage"], true);
}
@@ -3749,4 +4241,34 @@ mod tests {
ParseError::MissingArguments { .. }
));
}
// === Inspect / CDP URL ===
#[test]
fn test_inspect() {
let cmd = parse_command(&args("inspect"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "inspect");
}
#[test]
fn test_get_cdp_url() {
let cmd = parse_command(&args("get cdp-url"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "cdp_url");
}
// === Batch Tests ===
#[test]
fn test_batch_default() {
let cmd = parse_command(&args("batch"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "batch");
assert_eq!(cmd["bail"], false);
}
#[test]
fn test_batch_with_bail() {
let cmd = parse_command(&args("batch --bail"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "batch");
assert_eq!(cmd["bail"], true);
}
}
+163 -200
View File
@@ -26,6 +26,8 @@ pub struct Response {
pub success: bool,
pub data: Option<Value>,
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[allow(dead_code)]
@@ -120,6 +122,8 @@ fn get_pid_path(session: &str) -> PathBuf {
fn cleanup_stale_files(session: &str) {
let pid_path = get_pid_path(session);
let _ = fs::remove_file(&pid_path);
let stream_path = get_socket_dir().join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
#[cfg(unix)]
{
@@ -140,7 +144,7 @@ fn get_port_path(session: &str) -> PathBuf {
}
#[cfg(windows)]
fn get_port_for_session(session: &str) -> u16 {
pub fn get_port_for_session(session: &str) -> u16 {
let mut hash: i32 = 0;
for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
@@ -150,43 +154,19 @@ fn get_port_for_session(session: &str) -> u16 {
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
}
#[cfg(unix)]
fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(session);
if !pid_path.exists() {
return false;
}
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
unsafe {
if libc::kill(pid, 0) == 0 {
return true;
}
// EPERM means the process exists but we lack permission to
// signal it (e.g. inside a macOS sandbox). Only ESRCH means
// the process is genuinely gone.
return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
}
}
}
false
}
/// Read the actual daemon port from the `.port` file written by the daemon.
/// Falls back to the hash-derived port if the file does not exist or is
/// unreadable (e.g. daemon has not started yet).
#[cfg(windows)]
fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(session);
if !pid_path.exists() {
return false;
}
let port = get_port_for_session(session);
TcpStream::connect_timeout(
&format!("127.0.0.1:{}", port).parse().unwrap(),
Duration::from_millis(100),
)
.is_ok()
pub fn resolve_port(session: &str) -> u16 {
let port_path = get_port_path(session);
fs::read_to_string(&port_path)
.ok()
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or_else(|| get_port_for_session(session))
}
fn daemon_ready(session: &str) -> bool {
pub fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
@@ -194,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),
@@ -215,12 +195,15 @@ pub struct DaemonResult {
/// 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>,
@@ -232,7 +215,12 @@ pub struct DaemonOptions<'a> {
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
pub engine: Option<&'a str>,
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) {
@@ -242,6 +230,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
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);
}
@@ -260,6 +251,12 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
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");
}
@@ -293,11 +290,31 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
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> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
// Socket connectivity is the sole liveness check — no PID check — so
// callers in a different PID namespace (e.g. unshare) can still reuse
// an existing daemon they can reach over the socket.
if daemon_ready(session) {
// Double-check it's actually responsive by waiting and checking again
// This handles the race condition where daemon is shutting down
// (daemon has a 100ms shutdown delay, so we wait longer)
@@ -351,126 +368,54 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
}
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary)
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
// On Windows, canonicalize() returns \\?\ prefixed extended-length paths.
// Node.js cannot handle these, so strip the prefix.
#[allow(unused_assignments)]
let mut daemon_child: Option<std::process::Child> = None;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
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)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
{
use std::os::windows::process::CommandExt;
if opts.native {
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
} else {
// Default mode: spawn Node.js daemon (Playwright)
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
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())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
for _ in 0..50 {
@@ -479,21 +424,45 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
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));
}
#[cfg(unix)]
let endpoint_info = format!(
"socket: {}",
get_socket_dir()
.join(format!("{}.sock", session))
.display()
get_socket_dir().join(format!("{}.sock", session)).display()
);
#[cfg(windows)]
let endpoint_info = format!(
"port: 127.0.0.1:{}",
get_port_for_session(session)
);
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
Err(format!("Daemon failed to start ({})", endpoint_info))
}
@@ -508,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))
@@ -566,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> {
@@ -593,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"));
}
@@ -640,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()
@@ -652,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(),
@@ -665,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()
@@ -677,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"));
@@ -772,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
@@ -780,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);
}
}
+266 -57
View File
@@ -8,12 +8,53 @@ const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
/// Parse idle timeout from user-friendly format.
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
fn parse_idle_timeout(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Empty idle timeout".to_string());
}
// If the value ends with a unit suffix, convert it to milliseconds.
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
let ms = match unit {
"s" => num * 1000,
"m" => num * 60 * 1000,
"h" => num * 60 * 60 * 1000,
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
};
return Ok(ms.to_string());
}
// Pure numbers are already expressed in milliseconds.
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
Ok(s.to_string())
}
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
value.and_then(|raw| match parse_idle_timeout(&raw) {
Ok(ms) => Some(ms),
Err(e) => {
eprintln!(
"{} invalid idle timeout from {}: {}",
color::warning_indicator(),
source,
e
);
None
}
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Config {
pub headed: Option<bool>,
pub json: Option<bool>,
pub full: Option<bool>,
pub debug: Option<bool>,
pub session: Option<String>,
pub session_name: Option<String>,
@@ -41,7 +82,12 @@ pub struct Config {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>,
pub no_auto_dialog: Option<bool>,
}
impl Config {
@@ -49,7 +95,6 @@ impl Config {
Config {
headed: other.headed.or(self.headed),
json: other.json.or(self.json),
full: other.full.or(self.full),
debug: other.debug.or(self.debug),
session: other.session.or(self.session),
session_name: other.session_name.or(self.session_name),
@@ -83,7 +128,12 @@ impl Config {
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
engine: other.engine.or(self.engine),
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
screenshot_format: other.screenshot_format.or(self.screenshot_format),
idle_timeout: other.idle_timeout.or(self.idle_timeout),
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
}
}
}
@@ -91,7 +141,13 @@ impl Config {
fn read_config_file(path: &Path) -> Option<Config> {
let content = fs::read_to_string(path).ok()?;
match serde_json::from_str::<Config>(&content) {
Ok(config) => Some(config),
Ok(mut config) => {
config.idle_timeout = parse_idle_timeout_value(
config.idle_timeout.take(),
&format!("config file {}", path.display()),
);
Some(config)
}
Err(e) => {
eprintln!(
"{} invalid config file {}: {}",
@@ -158,6 +214,11 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--engine",
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
];
let mut i = 0;
while i < args.len() {
@@ -206,7 +267,6 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
pub struct Flags {
pub json: bool,
pub full: bool,
pub headed: bool,
pub debug: bool,
pub session: String,
@@ -225,6 +285,7 @@ pub struct Flags {
pub allow_file_access: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub force_launch: bool,
pub session_name: Option<String>,
pub annotate: bool,
pub color_scheme: Option<String>,
@@ -235,7 +296,12 @@ pub struct Flags {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
pub no_auto_dialog: bool,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -250,6 +316,7 @@ pub struct Flags {
pub cli_allow_file_access: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_headed: bool,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -276,7 +343,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION")
@@ -291,10 +357,20 @@ pub fn parse_flags(args: &[String]) -> Flags {
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy: env::var("AGENT_BROWSER_PROXY")
.ok()
.or(config.proxy)
.or_else(|| env::var("HTTP_PROXY").ok())
.or_else(|| env::var("http_proxy").ok())
.or_else(|| env::var("HTTPS_PROXY").ok())
.or_else(|| env::var("https_proxy").ok())
.or_else(|| env::var("ALL_PROXY").ok())
.or_else(|| env::var("all_proxy").ok()),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass),
.or(config.proxy_bypass)
.or_else(|| env::var("NO_PROXY").ok())
.or_else(|| env::var("no_proxy").ok()),
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
@@ -305,8 +381,11 @@ pub fn parse_flags(args: &[String]) -> Flags {
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(false),
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(true)),
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH")
|| env::var("CI").is_ok(),
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name),
@@ -340,7 +419,25 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
.ok()
.or(config.screenshot_dir),
screenshot_quality: env::var("AGENT_BROWSER_SCREENSHOT_QUALITY")
.ok()
.and_then(|s| s.parse().ok())
.or(config.screenshot_quality),
screenshot_format: env::var("AGENT_BROWSER_SCREENSHOT_FORMAT")
.ok()
.or(config.screenshot_format)
.filter(|s| s == "png" || s == "jpeg"),
idle_timeout: parse_idle_timeout_value(
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
)
.or(config.idle_timeout),
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|| config.no_auto_dialog.unwrap_or(false),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -352,6 +449,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_headed: false,
};
let mut i = 0;
@@ -364,16 +462,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--full" | "-f" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.full = val;
if consumed {
i += 1;
}
}
"--headed" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val;
flags.cli_headed = true;
if consumed {
i += 1;
}
@@ -391,6 +483,19 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--idle-timeout" => {
if let Some(s) = args.get(i + 1) {
match parse_idle_timeout(s) {
Ok(ms) => flags.idle_timeout = Some(ms),
Err(e) => eprintln!(
"{} Invalid --idle-timeout: {}",
color::warning_indicator(),
e
),
}
i += 1;
}
}
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
@@ -489,10 +594,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.auto_connect = val;
if !val {
flags.force_launch = true;
}
if consumed {
i += 1;
}
}
"--launch" | "--new" => {
flags.force_launch = true;
flags.auto_connect = false;
}
"--session-name" => {
if let Some(s) = args.get(i + 1) {
flags.session_name = Some(s.clone());
@@ -565,9 +677,51 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--native" => {
"--engine" => {
if let Some(s) = args.get(i + 1) {
flags.engine = Some(s.clone());
i += 1;
}
}
"--screenshot-dir" => {
if let Some(s) = args.get(i + 1) {
flags.screenshot_dir = Some(s.clone());
i += 1;
}
}
"--screenshot-quality" => {
if let Some(s) = args.get(i + 1) {
if let Ok(n) = s.parse::<u32>() {
if n <= 100 {
flags.screenshot_quality = Some(n);
} else {
eprintln!(
"{} --screenshot-quality must be 0-100, got {}",
color::warning_indicator(),
n
);
}
}
i += 1;
}
}
"--screenshot-format" => {
if let Some(s) = args.get(i + 1) {
if s == "png" || s == "jpeg" {
flags.screenshot_format = Some(s.clone());
} else {
eprintln!(
"{} --screenshot-format must be png or jpeg, got '{}'",
color::warning_indicator(),
s
);
}
i += 1;
}
}
"--no-auto-dialog" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
flags.no_auto_dialog = val;
if consumed {
i += 1;
}
@@ -590,16 +744,17 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Boolean flags that optionally take true/false
const GLOBAL_BOOL_FLAGS: &[&str] = &[
"--json",
"--full",
"--headed",
"--debug",
"--ignore-https-errors",
"--allow-file-access",
"--auto-connect",
"--launch",
"--new",
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
"--no-auto-dialog",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -625,6 +780,11 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--action-policy",
"--confirm-actions",
"--config",
"--engine",
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
];
let mut i = 0;
@@ -640,7 +800,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
i += 1;
continue;
}
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) || arg == "-f" {
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) {
if let Some(v) = args.get(i + 1) {
if matches!(v.as_str(), "true" | "false") {
i += 1;
@@ -669,6 +829,36 @@ mod tests {
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
}
#[test]
fn test_parse_idle_timeout_raw_ms() {
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
}
#[test]
fn test_parse_idle_timeout_seconds() {
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
}
#[test]
fn test_parse_idle_timeout_minutes() {
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
}
#[test]
fn test_parse_idle_timeout_hours() {
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
}
#[test]
fn test_parse_idle_timeout_rejects_capital_m() {
assert!(parse_idle_timeout("10M").is_err());
}
#[test]
fn test_parse_idle_timeout_rejects_unknown_unit() {
assert!(parse_idle_timeout("10x").is_err());
}
#[test]
fn test_parse_headers_flag_with_spaces() {
// Headers JSON is passed as a single quoted argument in shell
@@ -764,6 +954,18 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_idle_timeout_before_command() {
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_idle_timeout_flag_converts_to_ms() {
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
}
#[test]
fn test_parse_flags_with_session_and_executable_path() {
let flags = parse_flags(&args(
@@ -846,7 +1048,6 @@ mod tests {
let json = r#"{
"headed": true,
"json": true,
"full": true,
"debug": true,
"session": "test-session",
"sessionName": "my-app",
@@ -869,7 +1070,6 @@ mod tests {
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
assert_eq!(config.json, Some(true));
assert_eq!(config.full, Some(true));
assert_eq!(config.debug, Some(true));
assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app"));
@@ -969,6 +1169,22 @@ mod tests {
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_from_file_parses_idle_timeout() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-idle-timeout-config");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("test-config.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap();
let config = read_config_file(&config_path).unwrap();
assert_eq!(config.idle_timeout.as_deref(), Some("10000"));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_missing_file_returns_none() {
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
@@ -1129,36 +1345,6 @@ mod tests {
assert!(!flags.auto_connect);
}
#[test]
fn test_full_bare_defaults_true() {
let flags = parse_flags(&args("--full open example.com"));
assert!(flags.full);
}
#[test]
fn test_full_false() {
let flags = parse_flags(&args("--full false open example.com"));
assert!(!flags.full);
}
#[test]
fn test_full_short_flag() {
let flags = parse_flags(&args("-f open example.com"));
assert!(flags.full);
}
#[test]
fn test_clean_args_removes_full_with_value() {
let cleaned = clean_args(&args("--full false open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_short_full() {
let cleaned = clean_args(&args("-f open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_bool_flag_with_value() {
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
@@ -1215,4 +1401,27 @@ mod tests {
let merged = user.merge(project);
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
}
#[test]
fn test_no_auto_dialog_flag() {
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
assert!(flags.no_auto_dialog);
}
#[test]
fn test_no_auto_dialog_default_false() {
let flags = parse_flags(&args("open example.com"));
assert!(!flags.no_auto_dialog);
}
#[test]
fn test_clean_args_removes_no_auto_dialog() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--no-auto-dialog".to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
}
+757 -157
View File
@@ -1,167 +1,416 @@
use crate::color;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{exit, Command, Stdio};
const LAST_KNOWN_GOOD_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
pub fn get_browsers_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agent-browser")
.join("browsers")
}
pub fn find_installed_chrome() -> Option<PathBuf> {
let browsers_dir = get_browsers_dir();
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] home_dir={:?} browsers_dir={}",
dirs::home_dir(),
browsers_dir.display()
);
}
if !browsers_dir.exists() {
if debug {
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
}
return None;
}
let entries = match fs::read_dir(&browsers_dir) {
Ok(entries) => entries,
Err(e) => {
let _ = writeln!(
io::stderr(),
"Warning: cannot read Chrome cache directory {}: {}",
browsers_dir.display(),
e
);
return None;
}
};
let mut versions: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let matches = e
.file_name()
.to_str()
.is_some_and(|n| n.starts_with("chrome-"));
if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] entry {:?} matches={}",
e.file_name(),
matches
);
}
matches
})
.collect();
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for entry in versions {
let dir = entry.path();
if let Some(bin) = chrome_binary_in_dir(&dir) {
let exists = bin.exists();
if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] candidate {} exists={}",
bin.display(),
exists
);
}
if exists {
return Some(bin);
}
} else if debug {
let _ = writeln!(
io::stderr(),
"[chrome-search] no binary found in {}",
dir.display()
);
}
}
if debug {
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
}
None
}
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let app =
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if app.exists() {
return Some(app);
}
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if inner.exists() {
return Some(inner);
}
let inner_x64 = dir.join(
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
);
if inner_x64.exists() {
return Some(inner_x64);
}
None
}
#[cfg(target_os = "linux")]
{
let bin = dir.join("chrome");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-linux64/chrome");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(target_os = "windows")]
{
let bin = dir.join("chrome.exe");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-win64/chrome.exe");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}
fn platform_key() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"mac-arm64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"mac-x64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux64"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"win64"
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
{
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
// can still be used for other commands like `connect`. The install path
// guards against this at runtime before calling platform_key().
panic!("Unsupported platform for Chrome for Testing download")
}
}
async fn fetch_download_url() -> Result<(String, String), String> {
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
.await
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse version info: {}", e))?;
let channel = body
.get("channels")
.and_then(|c| c.get("Stable"))
.ok_or("No Stable channel found in version info")?;
let version = channel
.get("version")
.and_then(|v| v.as_str())
.ok_or("No version string found")?
.to_string();
let platform = platform_key();
let url = channel
.get("downloads")
.and_then(|d| d.get("chrome"))
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter().find_map(|entry| {
if entry.get("platform")?.as_str()? == platform {
Some(entry.get("url")?.as_str()?.to_string())
} else {
None
}
})
})
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
Ok((version, url))
}
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
let resp = reqwest::get(url)
.await
.map_err(|e| format!("Download failed: {}", e))?;
let total = resp.content_length();
let mut bytes = Vec::new();
let mut stream = resp;
let mut downloaded: u64 = 0;
let mut last_pct: u64 = 0;
loop {
let chunk = stream
.chunk()
.await
.map_err(|e| format!("Download error: {}", e))?;
match chunk {
Some(data) => {
downloaded += data.len() as u64;
bytes.extend_from_slice(&data);
if let Some(total) = total {
let pct = (downloaded * 100) / total;
if pct >= last_pct + 5 {
last_pct = pct;
let mb = downloaded as f64 / 1_048_576.0;
let total_mb = total as f64 / 1_048_576.0;
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
let _ = io::stderr().flush();
}
}
}
None => break,
}
}
eprintln!();
Ok(bytes)
}
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
let cursor = io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let enclosed = match file.enclosed_name() {
Some(name) => name.to_owned(),
None => continue,
};
let raw_name = enclosed.to_string_lossy().to_string();
// Strip the top-level "chrome-<platform>/" directory from zip entries.
// On Windows, enclosed_name() normalizes paths to backslashes, so we
// must split on either separator.
let rel_path = raw_name
.strip_prefix("chrome-")
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or(raw_name.clone());
if rel_path.is_empty() {
continue;
}
let out_path = dest.join(&rel_path);
// Defense-in-depth: ensure the resolved path is inside dest
if !out_path.starts_with(dest) {
continue;
}
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create parent dir {}: {}", parent.display(), e)
})?;
}
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
io::copy(&mut file, &mut out_file)
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
}
}
}
}
Ok(())
}
pub fn run_install(with_deps: bool) {
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
eprintln!(
"{} Chrome for Testing does not provide Linux ARM64 builds.",
color::error_indicator()
);
eprintln!(" Install Chromium from your system package manager instead:");
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
eprintln!(" sudo dnf install chromium # Fedora");
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
exit(1);
}
let is_linux = cfg!(target_os = "linux");
if is_linux {
if with_deps {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
"libxcb-shm0",
"libx11-xcb1",
"libx11-6",
"libxcb1",
"libxext6",
"libxrandr2",
"libxcomposite1",
"libxcursor1",
"libxdamage1",
"libxfixes3",
"libxi6",
"libgtk-3-0",
"libpangocairo-1.0-0",
"libpango-1.0-0",
"libatk1.0-0",
"libcairo-gobject2",
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
libasound,
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
"libnss3",
"libnspr4",
"libatk-bridge2.0-0",
"libdrm2",
"libxkbcommon0",
"libatspi2.0-0",
"libcups2",
"libxshmfence1",
"libgbm1",
],
)
} else if which_exists("dnf") {
(
"dnf",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
"libxcb",
"libX11-xcb",
"libX11",
"libXext",
"libXcursor",
"libXfixes",
"libXi",
"gtk3",
"cairo-gobject",
],
)
} else if which_exists("yum") {
(
"yum",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
],
)
} else {
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
let install_cmd = match pkg_mgr {
"apt-get" => {
format!(
"sudo apt-get update && sudo apt-get install -y {}",
deps.join(" ")
)
}
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
};
println!("Running: {}", install_cmd);
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
match status {
Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator())
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
}
install_linux_deps();
} else {
println!(
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("{}", color::cyan("Installing Chromium browser..."));
println!("{}", color::cyan("Installing Chrome..."));
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
// Pass the entire command as a single string to /c to handle paths with spaces.
#[cfg(windows)]
let status = Command::new("cmd")
.args(["/c", "npx playwright install chromium"])
.status();
#[cfg(not(windows))]
let status = Command::new("npx")
.args(["playwright", "install", "chromium"])
.status();
match status {
Ok(s) if s.success() => {
println!(
"{} Chromium installed successfully",
color::success_indicator()
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!(
"{} Failed to create runtime: {}",
color::error_indicator(),
e
);
exit(1);
});
let (version, url) = match rt.block_on(fetch_download_url()) {
Ok(v) => v,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
let dest = get_browsers_dir().join(format!("chrome-{}", version));
if let Some(bin) = chrome_binary_in_dir(&dest) {
if bin.exists() {
println!(
"{} Chrome {} is already installed",
color::success_indicator(),
version
);
return;
}
}
println!(" Downloading Chrome {} for {}", version, platform_key());
println!(" {}", url);
let bytes = match rt.block_on(download_bytes(&url)) {
Ok(b) => b,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
match extract_zip(bytes, &dest) {
Ok(()) => {
println!(
"{} Chrome {} installed successfully",
color::success_indicator(),
version
);
println!(" Location: {}", dest.display());
if is_linux && !with_deps {
println!();
println!(
@@ -171,25 +420,255 @@ pub fn run_install(with_deps: bool) {
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux {
println!(
"{} Try installing system dependencies first:",
color::yellow("Tip:")
);
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
let _ = fs::remove_dir_all(&dest);
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
}
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
match status {
Ok(s) if s.success() => {
println!(
"{} System dependencies installed",
color::success_indicator()
)
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!(
"{} Could not run install command: {}",
color::warning_indicator(),
e
),
}
}
fn install_linux_deps() {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
// part of the 64-bit time_t transition. Using the old names can cause
// apt to propose removing hundreds of system packages to resolve
// conflicts. We check for the t64 variant first to avoid this.
let apt_deps: Vec<&str> = vec![
("libxcb-shm0", None),
("libx11-xcb1", None),
("libx11-6", None),
("libxcb1", None),
("libxext6", None),
("libxrandr2", None),
("libxcomposite1", None),
("libxcursor1", None),
("libxdamage1", None),
("libxfixes3", None),
("libxi6", None),
("libgtk-3-0", Some("libgtk-3-0t64")),
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
("libpango-1.0-0", Some("libpango-1.0-0t64")),
("libatk1.0-0", Some("libatk1.0-0t64")),
("libcairo-gobject2", Some("libcairo-gobject2t64")),
("libcairo2", Some("libcairo2t64")),
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
("libxrender1", None),
("libasound2", Some("libasound2t64")),
("libfreetype6", None),
("libfontconfig1", None),
("libdbus-1-3", Some("libdbus-1-3t64")),
("libnss3", None),
("libnspr4", None),
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
("libdrm2", None),
("libxkbcommon0", None),
("libatspi2.0-0", Some("libatspi2.0-0t64")),
("libcups2", Some("libcups2t64")),
("libxshmfence1", None),
("libgbm1", None),
// Fonts: without actual font files, pages render with missing glyphs
// (tofu). This is especially visible for CJK and emoji characters.
("fonts-noto-color-emoji", None),
("fonts-noto-cjk", None),
("fonts-freefont-ttf", None),
]
.into_iter()
.map(|(base, t64_variant)| {
if let Some(t64) = t64_variant {
if package_exists_apt(t64) {
return t64;
}
}
base
})
.collect();
("apt-get", apt_deps)
} else if which_exists("dnf") {
(
"dnf",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
"libxcb",
"libX11-xcb",
"libX11",
"libXext",
"libXcursor",
"libXfixes",
"libXi",
"gtk3",
"cairo-gobject",
// Fonts
"google-noto-cjk-fonts",
"google-noto-emoji-color-fonts",
"liberation-fonts",
],
)
} else if which_exists("yum") {
(
"yum",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
// Fonts
"google-noto-cjk-fonts",
"liberation-fonts",
],
)
} else {
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
if pkg_mgr == "apt-get" {
// Run apt-get update first
println!("Running: sudo apt-get update");
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
match update_status {
Ok(s) if !s.success() => {
eprintln!(
"{} apt-get update failed. Continuing with existing package lists.",
color::warning_indicator()
);
}
Err(e) => {
eprintln!(
"{} Could not run apt-get update: {}",
color::warning_indicator(),
e
);
}
_ => {}
}
// Simulate the install first to detect if apt would remove any
// packages. This prevents the catastrophic scenario where installing
// these libraries triggers removal of hundreds of system packages
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
// t64 transition).
println!("Checking for conflicts...");
let sim_output = Command::new("sudo")
.args(["apt-get", "install", "--simulate"])
.args(&deps)
.output();
match sim_output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}\n{}", stdout, stderr);
// Count packages that would be removed
let removals: Vec<&str> = combined
.lines()
.filter(|line| line.starts_with("Remv "))
.collect();
if !removals.is_empty() {
eprintln!(
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
color::error_indicator(),
removals.len()
);
eprintln!(
" This usually means some package names have changed on your system"
);
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
eprintln!();
eprintln!(" Packages that would be removed:");
for line in removals.iter().take(20) {
eprintln!(" {}", line);
}
if removals.len() > 20 {
eprintln!(" ... and {} more", removals.len() - 20);
}
eprintln!();
eprintln!(" To install dependencies manually, run:");
eprintln!(" sudo apt-get install {}", deps.join(" "));
eprintln!();
eprintln!(" Review the apt output carefully before confirming.");
exit(1);
}
}
Err(e) => {
eprintln!(
"{} Could not simulate install ({}). Proceeding with caution.",
color::warning_indicator(),
e
);
}
}
// Safe to proceed: no removals detected
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
println!("Running: {}", install_cmd);
let status = Command::new("sudo")
.args(["apt-get", "install", "-y"])
.args(&deps)
.status();
report_install_status(status);
} else {
// dnf / yum path — these package managers do not remove packages
// during install, so the simulate-first guard is not needed.
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
println!("Running: {}", install_cmd);
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
report_install_status(status);
}
}
fn which_exists(cmd: &str) -> bool {
#[cfg(unix)]
{
@@ -223,3 +702,124 @@ fn package_exists_apt(pkg: &str) -> bool {
.map(|s| s.success())
.unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Dashboard install
// ---------------------------------------------------------------------------
pub fn get_dashboard_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agent-browser")
.join("dashboard")
}
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
fn dashboard_download_url() -> String {
format!(
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
DASHBOARD_VERSION
)
}
pub fn run_dashboard_install() {
println!("{}", color::cyan("Installing dashboard..."));
let dest = get_dashboard_dir();
if dest.join("index.html").exists() {
println!(
"{} Dashboard is already installed at {}",
color::success_indicator(),
dest.display()
);
return;
}
let url = dashboard_download_url();
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
println!(" {}", url);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!(
"{} Failed to create runtime: {}",
color::error_indicator(),
e
);
exit(1);
});
let bytes = match rt.block_on(download_bytes(&url)) {
Ok(b) => b,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
eprintln!(" The dashboard may not be available for this version yet.");
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
exit(1);
}
};
match extract_dashboard_zip(bytes, &dest) {
Ok(()) => {
println!(
"{} Dashboard v{} installed successfully",
color::success_indicator(),
DASHBOARD_VERSION
);
println!(" Location: {}", dest.display());
}
Err(e) => {
let _ = fs::remove_dir_all(&dest);
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
}
fn extract_dashboard_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
let cursor = io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let enclosed = match file.enclosed_name() {
Some(name) => name.to_owned(),
None => continue,
};
let rel_path = enclosed.to_string_lossy().to_string();
if rel_path.is_empty() || file.is_dir() {
if file.is_dir() {
let out_dir = dest.join(&rel_path);
let _ = fs::create_dir_all(&out_dir);
}
continue;
}
let out_path = dest.join(&rel_path);
if !out_path.starts_with(dest) {
continue;
}
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?;
}
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
io::copy(&mut file, &mut out_file)
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
}
Ok(())
}
+753 -261
View File
File diff suppressed because it is too large Load Diff
+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");
}
}
+500 -43
View File
@@ -23,6 +23,16 @@ pub struct OutputOptions {
pub max_output: Option<usize>,
}
impl OutputOptions {
pub fn from_flags(flags: &crate::flags::Flags) -> Self {
Self {
json: flags.json,
content_boundaries: flags.content_boundaries,
max_output: flags.max_output,
}
}
}
fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
let Some(limit) = max else {
return content.to_string();
@@ -64,6 +74,62 @@ fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptio
}
}
fn format_storage_value(value: &serde_json::Value) -> String {
value
.as_str()
.map(ToString::to_string)
.unwrap_or_else(|| serde_json::to_string(value).unwrap_or_default())
}
fn format_storage_text(data: &serde_json::Value) -> Option<String> {
if let Some(entries) = data.get("data").and_then(|v| v.as_object()) {
if entries.is_empty() {
return Some("No storage entries".to_string());
}
let lines = entries
.iter()
.map(|(key, value)| format!("{}: {}", key, format_storage_value(value)))
.collect::<Vec<_>>();
return Some(lines.join("\n"));
}
let key = data.get("key").and_then(|v| v.as_str())?;
let value = data.get("value")?;
Some(format!("{}: {}", key, format_storage_value(value)))
}
fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) -> Option<String> {
match action {
Some("stream_disable") => data
.get("disabled")
.and_then(|v| v.as_bool())
.filter(|disabled| *disabled)
.map(|_| "Streaming disabled".to_string()),
Some("stream_enable") | Some("stream_status") => {
let enabled = data.get("enabled").and_then(|v| v.as_bool())?;
if !enabled {
return Some("Streaming disabled".to_string());
}
let port = data.get("port").and_then(|v| v.as_u64())?;
let connected = data
.get("connected")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let screencasting = data
.get("screencasting")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Some(format!(
"Streaming enabled on ws://127.0.0.1:{port}\nConnected: {connected}\nScreencasting: {screencasting}"
))
}
_ => None,
}
}
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
if opts.json {
if opts.content_boundaries {
@@ -87,6 +153,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
} else {
println!("{}", serde_json::to_string(resp).unwrap_or_default());
}
// JSON mode includes the warning field in the JSON payload already
return;
}
@@ -96,10 +163,69 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
color::error_indicator(),
resp.error.as_deref().unwrap_or("Unknown error")
);
// Still print dialog warning after errors, since a pending dialog
// is the most common cause of commands timing out
if let Some(ref warning) = resp.warning {
eprintln!("{} {}", color::warning_indicator(), warning);
}
return;
}
if let Some(data) = &resp.data {
// Dialog status response
if action == Some("dialog") {
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
if has_dialog {
let dtype = data
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let message = data.get("message").and_then(|v| v.as_str()).unwrap_or("");
println!(
"{} JavaScript {} dialog is open: \"{}\"",
color::warning_indicator(),
dtype,
message
);
if let Some(default_prompt) = data.get("defaultPrompt").and_then(|v| v.as_str())
{
println!(" Default prompt text: \"{}\"", default_prompt);
}
println!(" Use `dialog accept [text]` or `dialog dismiss` to resolve it");
} else {
println!("{} No dialog is currently open", color::success_indicator());
}
print_warning(resp);
return;
}
}
if let Some(output) = format_stream_status_text(action, data) {
println!("{}", output);
return;
}
if action == Some("storage_get") {
if let Some(output) = format_storage_text(data) {
println!("{}", output);
return;
}
}
// Inspect response (check before generic URL handler since it also has a "url" field)
if action == Some("inspect") {
let opened = data
.get("opened")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if opened {
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
println!("{} Opened DevTools: {}", color::success_indicator(), url);
} else {
println!("{} Opened DevTools", color::success_indicator());
}
} else if let Some(err) = data.get("error").and_then(|v| v.as_str()) {
eprintln!("Could not open DevTools: {}", err);
}
return;
}
// Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
@@ -110,6 +236,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
println!("{}", url);
return;
}
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
println!("{}", cdp_url);
return;
}
// Diff responses -- route by action to avoid fragile shape probing
if let Some(obj) = data.as_object() {
match action {
@@ -322,16 +452,25 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.get("resourceType")
.and_then(|v| v.as_str())
.unwrap_or("");
println!("{} {} ({})", method, url, resource_type);
let request_id = req.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
let status = req.get("status").and_then(|v| v.as_i64());
match status {
Some(s) => println!(
"[{}] {} {} ({}) {}",
request_id, method, url, resource_type, s
),
None => println!("[{}] {} {} ({})", request_id, method, url, resource_type),
}
}
}
return;
}
// Cleared (cookies or request log)
// Cleared (cookies, console, or request log)
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) {
if cleared {
let label = match action {
Some("cookies_clear") => "Cookies cleared",
Some("console") => "Console log cleared",
_ => "Request log cleared",
};
println!("{} {}", color::success_indicator(), label);
@@ -404,13 +543,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
println!("{} {}", color::success_indicator(), label);
return;
}
// Recording start (has "started" field)
// Started actions (profiling, HAR, recording)
if let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
if started {
match action {
Some("profiler_start") => {
println!("{} Profiling started", color::success_indicator());
}
Some("har_start") => {
println!("{} HAR recording started", color::success_indicator());
}
_ => {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("{} Recording started: {}", color::success_indicator(), path);
@@ -539,9 +681,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0)
),
"har_stop" => println!(
"{} HAR saved to {}",
"{} HAR saved to {} ({} requests)",
color::success_indicator(),
color::green(path)
color::green(path),
data.get("requestCount")
.and_then(|c| c.as_u64())
.unwrap_or(0)
),
"download" | "waitfordownload" => println!(
"{} Download saved to {}",
@@ -812,6 +957,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// Default success
println!("{} Done", color::success_indicator());
}
print_warning(resp);
}
fn print_warning(resp: &Response) {
if let Some(ref warning) = resp.warning {
eprintln!("{} {}", color::warning_indicator(), warning);
}
}
/// Print command-specific help. Returns true if help was printed, false if command unknown.
@@ -1292,12 +1445,18 @@ Modes:
--url <pattern> Wait for URL to match pattern
--load <state> Wait for load state (load, domcontentloaded, networkidle)
--fn <expression> Wait for JavaScript expression to be truthy
--text <text> Wait for text to appear on page
--text <text> Wait for text to appear on page (substring match)
--download [path] Wait for a download to complete (optionally save to path)
Download Options (with --download):
--timeout <ms> Timeout in milliseconds for download to start
Wait for text to disappear:
Use --fn or --state hidden to wait for text or elements to go away:
wait --fn "!document.body.innerText.includes('Loading...')"
wait "#spinner" --state hidden
wait @e5 --state detached
Global Options:
--json Output as JSON
--session <name> Use specific session
@@ -1311,6 +1470,7 @@ Examples:
agent-browser wait --text "Welcome back"
agent-browser wait --download ./file.pdf
agent-browser wait --download ./report.xlsx --timeout 30000
agent-browser wait --fn "!document.body.innerText.includes('Loading...')"
"##
}
@@ -1319,7 +1479,7 @@ Examples:
r##"
agent-browser screenshot - Take a screenshot
Usage: agent-browser screenshot [path]
Usage: agent-browser screenshot [selector] [path]
Captures a screenshot of the current page. If no path is provided,
saves to a temporary directory with a generated filename.
@@ -1330,6 +1490,13 @@ Options:
Each label [N] corresponds to ref @eN from snapshot.
Prints a legend mapping labels to element roles/names.
With --json, annotations are included in the response.
Supported on Chromium and Lightpanda.
--screenshot-dir <path> Default output directory for screenshots
(or AGENT_BROWSER_SCREENSHOT_DIR env)
--screenshot-quality <0-100> JPEG quality (0-100, only applies to jpeg format)
(or AGENT_BROWSER_SCREENSHOT_QUALITY env)
--screenshot-format <fmt> Image format: png (default) or jpeg
(or AGENT_BROWSER_SCREENSHOT_FORMAT env)
Global Options:
--json Output as JSON
@@ -1342,6 +1509,8 @@ Examples:
agent-browser screenshot --annotate # Labeled screenshot + legend
agent-browser screenshot --annotate ./page.png # Save annotated screenshot
agent-browser screenshot --annotate --json # JSON output with annotations
agent-browser screenshot --screenshot-dir ./shots # Save to custom directory
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
"##
}
"pdf" => {
@@ -1375,7 +1544,6 @@ Designed for AI agents to understand page structure.
Options:
-i, --interactive Only include interactive elements
-C, --cursor Include cursor-interactive elements (cursor:pointer, onclick, tabindex)
-c, --compact Remove empty structural elements
-d, --depth <n> Limit tree depth
-s, --selector <sel> Scope snapshot to CSS selector
@@ -1387,7 +1555,6 @@ Global Options:
Examples:
agent-browser snapshot
agent-browser snapshot -i
agent-browser snapshot -i -C # Interactive + cursor-interactive elements
agent-browser snapshot --compact --depth 5
agent-browser snapshot -s "#main-content"
"##
@@ -1429,12 +1596,15 @@ Examples:
r##"
agent-browser close - Close the browser
Usage: agent-browser close
Usage: agent-browser close [options]
Closes the browser instance for the current session.
Aliases: quit, exit
Options:
--all Close all active sessions
Global Options:
--json Output as JSON
--session <name> Use specific session
@@ -1442,6 +1612,26 @@ Global Options:
Examples:
agent-browser close
agent-browser close --session mysession
agent-browser close --all
"##
}
// === Inspect ===
"inspect" => {
r##"
agent-browser inspect - Open Chrome DevTools for the active page
Starts a local WebSocket proxy and opens Chrome's DevTools frontend in your
default browser. The proxy routes DevTools traffic through the daemon's
existing CDP connection, so both DevTools and agent-browser commands work
simultaneously.
Usage: agent-browser inspect
Examples:
agent-browser open example.com
agent-browser inspect # opens DevTools in your browser
agent-browser click "Submit" # commands still work while DevTools is open
"##
}
@@ -1464,6 +1654,7 @@ Subcommands:
count <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height)
styles <selector> Get computed styles of elements
cdp-url Get Chrome DevTools Protocol WebSocket URL
Global Options:
--json Output as JSON
@@ -1590,7 +1781,7 @@ Usage: agent-browser set <setting> [args]
Configures various browser settings and emulation options.
Settings:
viewport <w> <h> Set viewport size
viewport <w> <h> [scale] Set viewport size (scale = deviceScaleFactor, e.g. 2 for retina)
device <name> Emulate device (e.g., "iPhone 12")
geo <lat> <lng> Set geolocation
offline [on|off] Toggle offline mode
@@ -1605,6 +1796,7 @@ Global Options:
Examples:
agent-browser set viewport 1920 1080
agent-browser set viewport 1920 1080 2 # 2x retina
agent-browser set device "iPhone 12"
agent-browser set geo 37.7749 -122.4194
agent-browser set offline on
@@ -1632,6 +1824,11 @@ Subcommands:
requests [options] List captured requests
--clear Clear request log
--filter <pattern> Filter by URL pattern
--type <types> Filter by resource type (comma-separated: xhr,fetch,document)
--method <method> Filter by HTTP method (GET, POST, etc.)
--status <code> Filter by status (200, 2xx, 400-499)
request <requestId> View full request/response detail (including body)
har <start|stop> [path] Record and export a HAR file
Global Options:
--json Output as JSON
@@ -1643,7 +1840,12 @@ Examples:
agent-browser network unroute
agent-browser network requests
agent-browser network requests --filter "api"
agent-browser network requests --type xhr,fetch
agent-browser network requests --method POST --status 2xx
agent-browser network requests --clear
agent-browser network request 1234.5
agent-browser network har start
agent-browser network har stop ./capture.har
"##
}
@@ -1816,7 +2018,7 @@ Usage: agent-browser auth <subcommand> [args]
Subcommands:
save <name> Save credentials for a login profile
login <name> Login using saved credentials
login <name> Login using saved credentials (waits for form fields)
list List saved profiles (names and URLs only)
show <name> Show profile metadata (no passwords)
delete <name> Delete a saved profile
@@ -1830,6 +2032,10 @@ Save Options:
--password-selector <s> Custom CSS selector for password field
--submit-selector <s> Custom CSS selector for submit button
Login behavior:
auth login waits for form selectors to appear before filling/clicking.
Selector wait timeout follows the default action timeout.
Global Options:
--json Output as JSON
--session <name> Use specific session
@@ -1870,13 +2076,14 @@ Examples:
r##"
agent-browser dialog - Handle browser dialogs
Usage: agent-browser dialog <response> [text]
Usage: agent-browser dialog <accept|dismiss|status> [text]
Respond to browser dialogs (alert, confirm, prompt).
Respond to or check for browser dialogs (alert, confirm, prompt).
Operations:
accept [text] Accept dialog, optionally with prompt text
dismiss Dismiss/cancel dialog
status Check if a dialog is currently open
Global Options:
--json Output as JSON
@@ -1886,6 +2093,7 @@ Examples:
agent-browser dialog accept
agent-browser dialog accept "my input"
agent-browser dialog dismiss
agent-browser dialog status
"##
}
@@ -1896,7 +2104,7 @@ agent-browser trace - Record execution trace
Usage: agent-browser trace <operation> [path]
Record a trace for debugging with Playwright Trace Viewer.
Record a Chrome DevTools trace for debugging.
Operations:
start [path] Start recording trace
@@ -1963,7 +2171,7 @@ Usage: agent-browser record start <path.webm> [url]
agent-browser record stop
agent-browser record restart <path.webm> [url]
Record the browser to a WebM video file using Playwright's native recording.
Record the browser to a WebM video file.
Creates a fresh browser context but preserves cookies and localStorage.
If no URL is provided, automatically navigates to your current page.
@@ -2053,6 +2261,33 @@ Examples:
"##
}
// === Clipboard ===
"clipboard" => {
r##"
agent-browser clipboard - Read and write clipboard
Usage: agent-browser clipboard <operation> [text]
Read from or write to the browser clipboard.
Operations:
read Read text from clipboard
write <text> Write text to clipboard
copy Copy current selection (simulates Ctrl+C)
paste Paste from clipboard (simulates Ctrl+V)
Global Options:
--json Output as JSON
--session <name> Use specific session
Examples:
agent-browser clipboard read
agent-browser clipboard write "Hello, World!"
agent-browser clipboard copy
agent-browser clipboard paste
"##
}
// === State ===
"state" => {
r##"
@@ -2141,6 +2376,56 @@ Examples:
"##
}
// === Upgrade ===
"upgrade" => {
r##"
agent-browser upgrade - Upgrade to the latest version
Usage: agent-browser upgrade
Detects the current installation method (npm, Homebrew, or Cargo) and runs
the appropriate update command. Displays the version change on success, or
informs you if you are already on the latest version.
Examples:
agent-browser upgrade
"##
}
// === Dashboard ===
"dashboard" => {
r##"
agent-browser dashboard - Observability dashboard
Usage: agent-browser dashboard [start|stop|install] [options]
Manage the observability dashboard, a local web UI that shows live
browser viewports and command activity feeds for all sessions.
Subcommands:
start [--port <n>] Start the dashboard server (default port: 4848)
stop Stop the dashboard server
install Download and install the dashboard to ~/.agent-browser/dashboard/
Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard start'.
The dashboard runs as a standalone background process, independent of
browser sessions. All sessions automatically stream to the dashboard.
Options:
--port <n> Port for the dashboard server (default: 4848)
Global Options:
--json Output as JSON
Examples:
agent-browser dashboard install
agent-browser dashboard start
agent-browser dashboard start --port 8080
agent-browser dashboard stop
"##
}
// === Connect ===
"connect" => {
r##"
@@ -2181,6 +2466,39 @@ Examples:
"##
}
// === Runtime streaming ===
"stream" => {
r##"
agent-browser stream - Manage live WebSocket browser streaming
Usage:
agent-browser stream enable [--port <port>]
agent-browser stream disable
agent-browser stream status
Enables or disables the session-scoped WebSocket stream server without restarting
an already-running daemon. If --port is omitted, agent-browser binds an
available localhost port automatically and reports it back.
Notes:
- 'stream enable' creates the WebSocket server.
- WebSocket clients trigger frame streaming automatically.
- 'screencast_start' and 'screencast_stop' still control explicit CDP screencasts.
- Streaming is always enabled. Set AGENT_BROWSER_STREAM_PORT to bind to a
specific port instead of the default OS-assigned port.
Global Options:
--json Output as JSON
--session <name> Use specific session
Examples:
agent-browser stream status
agent-browser stream enable
agent-browser stream enable --port 9223
agent-browser stream disable
"##
}
// === iOS Commands ===
"tap" => {
r##"
@@ -2302,6 +2620,38 @@ Examples:
"##
}
"batch" => {
r##"
agent-browser batch - Execute multiple commands from stdin
Usage: echo '<json>' | agent-browser batch [options]
Reads a JSON array of commands from stdin and executes them sequentially.
Each command is an array of strings matching normal CLI arguments.
Results are printed in order, separated by blank lines (or as a JSON array
with --json).
Options:
--bail Stop on first error (default: continue all commands)
--json Output results as a JSON array
Input Format:
A JSON array of string arrays. Each inner array is one command:
[
["open", "https://example.com"],
["snapshot", "-i"],
["click", "@e1"],
["fill", "@e2", "test@example.com"],
["screenshot", "result.png"]
]
Examples:
echo '[["open", "https://example.com"], ["snapshot"]]' | agent-browser batch
echo '[["open", "https://example.com"], ["get", "title"]]' | agent-browser batch --json
agent-browser batch --bail < commands.json
"##
}
_ => return false,
};
println!("{}", help.trim());
@@ -2340,7 +2690,7 @@ Core Commands:
snapshot Accessibility tree with refs (for AI)
eval <js> Run JavaScript
connect <port|url> Connect to browser via CDP
close Close browser
close [--all] Close browser (--all closes every session)
Navigation:
back Go back
@@ -2348,7 +2698,7 @@ Navigation:
reload Reload page
Get Info: agent-browser get <what> [selector]
text, html, value, attr <name>, title, url, count, box, styles
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
Check State: agent-browser is <what> <selector>
visible, enabled, checked
@@ -2368,6 +2718,7 @@ Network: agent-browser network <action>
route <url> [--abort|--body <json>]
unroute [url]
requests [--clear] [--filter <pattern>]
har <start|stop> [path]
Storage:
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
@@ -2382,17 +2733,28 @@ Diff:
diff url <u1> <u2> Compare two pages
Debug:
trace start|stop [path] Record Playwright trace
trace start|stop [path] Record Chrome DevTools trace
profiler start|stop [path] Record Chrome DevTools profile
record start <path> [url] Start video recording (WebM)
record stop Stop and save video
console [--clear] View console logs
errors [--clear] View page errors
highlight <sel> Highlight element
inspect Open Chrome DevTools for the active page
clipboard <op> [text] Read/write clipboard (read, write, copy, paste)
Streaming:
stream enable [--port <n>] Start runtime WebSocket streaming for this session
stream disable Stop runtime WebSocket streaming
stream status Show streaming status and active port
Batch:
batch [--bail] Execute commands from stdin (JSON array of string arrays)
--bail stops on first error (default: continue all)
Auth Vault:
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
auth login <name> Login using saved credentials
auth login <name> Login using saved credentials (waits for form fields)
auth list List saved auth profiles
auth show <name> Show auth profile metadata
auth delete <name> Delete auth profile
@@ -2405,9 +2767,16 @@ Sessions:
session Show current session name
session list List active sessions
Dashboard:
dashboard [start] Start the dashboard server (default port: 4848)
dashboard start --port <n> Start on a specific port
dashboard stop Stop the dashboard server
Setup:
install Install browser binaries
install --with-deps Also install system dependencies (Linux)
upgrade Upgrade to the latest version
dashboard install Install the observability dashboard
Snapshot Options:
-i, --interactive Only interactive elements
@@ -2415,40 +2784,50 @@ Snapshot Options:
-d, --depth <n> Limit tree depth
-s, --selector <sel> Scope to CSS selector
Authentication:
--profile <path> Persist login sessions across restarts (cookies, IndexedDB, cache)
(or AGENT_BROWSER_PROFILE env)
--session-name <name> Auto-save/restore cookies and localStorage by name
(or AGENT_BROWSER_SESSION_NAME env)
--state <path> Load saved auth state (cookies + storage) from JSON file
(or AGENT_BROWSER_STATE env)
--auto-connect Connect to a running Chrome (DEFAULT - shares cookies/sessions)
Tip: enable CDP via chrome://inspect/#remote-debugging
--launch, --new Launch a fresh browser instead of connecting to existing
--headers <json> HTTP headers scoped to URL's origin (e.g., Authorization bearer token)
Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--profile <path> Persistent browser profile (or AGENT_BROWSER_PROFILE env)
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable)
--args <args> Browser launch args, comma or newline separated (or AGENT_BROWSER_ARGS)
e.g., --args "--no-sandbox,--disable-blink-features=AutomationControlled"
--user-agent <ua> Custom User-Agent (or AGENT_BROWSER_USER_AGENT)
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY)
e.g., --proxy "http://user:pass@127.0.0.1:7890"
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS)
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY, HTTP_PROXY, HTTPS_PROXY, ALL_PROXY)
Supports authenticated proxies: --proxy "http://user:pass@127.0.0.1:7890"
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS, NO_PROXY)
e.g., --proxy-bypass "localhost,*.internal.com"
--ignore-https-errors Ignore HTTPS certificate errors
--allow-file-access Allow file:// URLs to access local files (Chromium only)
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
--device <name> iOS device name (e.g., "iPhone 15 Pro")
--json JSON output
--full, -f Full page screenshot
--annotate Annotated screenshot with numbered labels and legend
--headed Show browser window (not headless)
--screenshot-dir <path> Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR)
--screenshot-quality <n> JPEG quality 0-100; ignored for PNG (or AGENT_BROWSER_SCREENSHOT_QUALITY)
--screenshot-format <fmt> Screenshot format: png, jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT)
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
--engine <name> Browser engine: chrome (default), lightpanda (or AGENT_BROWSER_ENGINE)
--no-auto-dialog Disable automatic dismissal of alert/beforeunload dialogs (or AGENT_BROWSER_NO_AUTO_DIALOG)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
@@ -2482,20 +2861,20 @@ Environment:
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
AGENT_BROWSER_HEADED Show browser window (not headless)
AGENT_BROWSER_JSON JSON output
AGENT_BROWSER_FULL Full page screenshot
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend
AGENT_BROWSER_DEBUG Debug output
AGENT_BROWSER_IGNORE_HTTPS_ERRORS Ignore HTTPS certificate errors
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, browserless, agentcore)
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
AGENT_BROWSER_STREAM_PORT Override WebSocket streaming port (default: OS-assigned)
AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default)
AGENT_BROWSER_IOS_DEVICE Default iOS device name
AGENT_BROWSER_IOS_UDID Default iOS device UDID
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
@@ -2504,14 +2883,20 @@ Environment:
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
AGENT_BROWSER_NO_AUTO_DIALOG Disable automatic dismissal of alert/beforeunload dialogs
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
HTTP_PROXY / HTTPS_PROXY Standard proxy env vars (fallback if AGENT_BROWSER_PROXY not set)
ALL_PROXY SOCKS proxy (fallback for proxy)
NO_PROXY Bypass proxy for hosts (fallback for proxy-bypass)
AGENT_BROWSER_SCREENSHOT_DIR Default screenshot output directory
AGENT_BROWSER_SCREENSHOT_QUALITY JPEG quality 0-100
AGENT_BROWSER_SCREENSHOT_FORMAT Screenshot format: png, jpeg
Install (recommended, fastest - native Rust CLI):
npm install -g agent-browser
agent-browser install # Download Chromium (first time)
Try without installing (slower, routes through Node.js):
npx agent-browser open example.com
Install:
npm install -g agent-browser # npm
brew install agent-browser # Homebrew
cargo install agent-browser # Cargo
agent-browser install # Download Chrome (first time)
Examples:
agent-browser open example.com
@@ -2525,6 +2910,8 @@ Examples:
agent-browser wait --load networkidle # Wait for slow pages to load
agent-browser --cdp 9222 snapshot # Connect via CDP port
agent-browser --auto-connect snapshot # Auto-discover running Chrome
agent-browser stream enable # Start runtime streaming on an auto-selected port
agent-browser stream status # Inspect runtime streaming state
agent-browser --color-scheme dark open example.com # Dark mode
agent-browser --profile ~/.myapp open example.com # Persistent profile
agent-browser --session-name myapp open example.com # Auto-save/restore state
@@ -2625,3 +3012,73 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
pub fn print_version() {
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
}
#[cfg(test)]
mod tests {
use super::format_storage_text;
use serde_json::json;
#[test]
fn test_format_stream_status_text_for_enabled_stream() {
let data = json!({
"enabled": true,
"port": 9223,
"connected": true,
"screencasting": false
});
let rendered = super::format_stream_status_text(Some("stream_status"), &data).unwrap();
assert_eq!(
rendered,
"Streaming enabled on ws://127.0.0.1:9223\nConnected: true\nScreencasting: false"
);
}
#[test]
fn test_format_stream_status_text_for_disabled_stream() {
let data =
json!({ "enabled": false, "port": null, "connected": false, "screencasting": false });
let rendered = super::format_stream_status_text(Some("stream_status"), &data).unwrap();
assert_eq!(rendered, "Streaming disabled");
}
#[test]
fn test_format_storage_text_for_all_entries() {
let data = json!({
"data": {
"token": "abc123",
"user": "alice"
}
});
let rendered = format_storage_text(&data).unwrap();
assert_eq!(rendered, "token: abc123\nuser: alice");
}
#[test]
fn test_format_storage_text_for_key_lookup() {
let data = json!({
"key": "token",
"value": "abc123"
});
let rendered = format_storage_text(&data).unwrap();
assert_eq!(rendered, "token: abc123");
}
#[test]
fn test_format_storage_text_for_empty_store() {
let data = json!({
"data": {}
});
let rendered = format_storage_text(&data).unwrap();
assert_eq!(rendered, "No storage entries");
}
}
+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
-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/vercel-labs/agent-browser
Documentation: https://agent-browser.dev
npm package: agent-browser
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
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 });
}
}
-139
View File
@@ -1,139 +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:
```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)
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
```
## 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>--profile &lt;path&gt;</code></td><td>Persistent browser profile directory</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</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
-333
View File
@@ -1,333 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("commands")
# Commands
## Core
```bash
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser click <sel> # Click element (--new-tab to open in new tab)
agent-browser dblclick <sel> # Double-click
agent-browser fill <sel> <text> # Clear and fill
agent-browser type <sel> <text> # Type into element
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
agent-browser keyboard type <text> # Type at current focus (no selector needed)
agent-browser keyboard inserttext <text> # Insert text without key events
agent-browser keydown <key> # Hold key down
agent-browser keyup <key> # Release key
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 close # Close browser (aliases: quit, exit)
```
## 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 --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
```
## 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.
## 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
```
## 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
```
## Auth vault
```bash
agent-browser auth save <name> [opts] # Save auth profile
agent-browser auth login <name> # Login using saved credentials
agent-browser auth list # List saved profiles (names and URLs only)
agent-browser auth show <name> # Show profile metadata (no passwords)
agent-browser auth delete <name> # Delete a saved profile
```
Save options:
- `--url <url>` -- login page URL (required)
- `--username <user>` -- username (required)
- `--password <pass>` -- password (required unless `--password-stdin`)
- `--password-stdin` -- read password from stdin (recommended to avoid shell history exposure)
- `--username-selector <sel>` -- custom CSS selector for username field
- `--password-selector <sel>` -- custom CSS selector for password field
- `--submit-selector <sel>` -- custom CSS selector for submit button
```bash
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
agent-browser auth login github
agent-browser auth list
```
## Confirmation
When `--confirm-actions` is set, certain action categories return a `confirmation_required` response instead of executing immediately. Use `confirm` or `deny` to approve or reject the action.
```bash
agent-browser confirm <confirmation-id> # Approve a pending action
agent-browser deny <confirmation-id> # Deny a pending action
```
Pending confirmations auto-deny after 60 seconds.
```bash
agent-browser --confirm-actions eval,download eval "document.title"
# Returns confirmation_required with ID
agent-browser confirm c_8f3a1234
```
## State management
```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 (cookies, localStorage)
--profile <path> # Persistent browser profile directory
--state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable
--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)
-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
--color-scheme <scheme> # Color scheme: dark, light, no-preference
--download-path <path> # Default download directory
--content-boundaries # Wrap page output in boundary markers for LLM safety
--max-output <chars> # Truncate page output to N characters
--allowed-domains <list> # Comma-separated allowed domain patterns
--action-policy <path> # Path to action policy JSON file
--confirm-actions <list> # Action categories requiring confirmation
--confirm-interactive # Interactive confirmation prompts (auto-denies if stdin is not a TTY)
--config <path> # Use a custom config file
--debug # Debug output
```
## 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.
-199
View File
@@ -1,199 +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.
## 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",
"profile": "./browser-data",
"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>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
<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>contentBoundaries</code></td><td><code>--content-boundaries</code></td><td>boolean</td></tr>
<tr><td><code>maxOutput</code></td><td><code>--max-output</code></td><td>number</td></tr>
<tr><td><code>allowedDomains</code></td><td><code>--allowed-domains</code></td><td>string[]</td></tr>
<tr><td><code>actionPolicy</code></td><td><code>--action-policy</code></td><td>string</td></tr>
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
<tr><td><code>native</code></td><td><code>--native</code></td><td>boolean (experimental)</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
</tbody>
</table>
## Common Configurations
### Local Development
```json
{
"headed": true,
"profile": "./browser-data"
}
```
### 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"
}
```
### AI Agent Security
```json
{
"contentBoundaries": true,
"maxOutput": 50000,
"allowedDomains": ["your-app.com", "*.your-app.com"],
"actionPolicy": "./policy.json"
}
```
## Overriding Boolean Options
Boolean flags accept an optional `true`/`false` value to override config settings:
```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`, `--content-boundaries`, `--confirm-interactive`, `--native`.
## 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_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.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers for LLM safety.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output (truncates beyond limit).</td><td>(unlimited)</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns (e.g., <code>example.com,*.example.com</code>).</td><td>(unrestricted)</td></tr>
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</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;
}
-134
View File
@@ -1,134 +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
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 install # Download Chromium (first time only)
npx agent-browser open example.com
```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
## Project installation (local dependency)
For projects that want to pin the version in `package.json`:
```bash
npm install agent-browser
npx agent-browser install
```
Then use via `npx` or `package.json` scripts:
```bash
npx agent-browser open example.com
```
## Homebrew (macOS)
```bash
brew install agent-browser
agent-browser install # Download Chromium
```
## From source
```bash
git clone https://github.com/vercel-labs/agent-browser
cd agent-browser
pnpm install
pnpm build
pnpm build:native
./bin/agent-browser install
pnpm link --global
```
## 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';
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 vercel-labs/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);
}
-69
View File
@@ -1,69 +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 # all platforms (fastest, native Rust CLI)
brew install agent-browser # macOS
# or try without installing
npx agent-browser open example.com
```
## 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
## 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
```
-196
View File
@@ -1,196 +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
## Persistent profiles
By default, browser state is lost when the browser closes. Use `--profile` to persist state across restarts:
```bash
# Use a persistent profile directory
agent-browser --profile ~/.myapp-profile open myapp.com
# Login once, then reuse the authenticated session
agent-browser --profile ~/.myapp-profile open myapp.com/dashboard
# Or via environment variable
AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
```
The profile directory stores:
- Cookies and localStorage
- IndexedDB data
- Service workers
- Browser cache
- Login sessions
## 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
```
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';
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>
);
}
-282
View File
@@ -1,282 +0,0 @@
"use client";
function DiffLine({ line }: { line: string }) {
if (line.startsWith("+ ")) {
return <div className="text-green-400">{line}</div>;
}
if (line.startsWith("- ")) {
return <div className="text-red-400">{line}</div>;
}
return <div className="opacity-50">{line}</div>;
}
function CommandLine({ children }: { children: string }) {
return (
<div>
<span className="opacity-40">$ </span>
{children}
</div>
);
}
function Terminal({ children }: { children: React.ReactNode }) {
return (
<div
className="rounded border font-mono text-[0.8125rem] leading-[1.7] overflow-x-auto"
style={{
background: "var(--card)",
borderColor: "var(--border)",
padding: "0.875rem",
}}
>
{children}
</div>
);
}
function PageMockup({
label,
buttonColor,
diffMode,
}: {
label: string;
buttonColor: string;
diffMode?: boolean;
}) {
const dimOpacity = diffMode ? 0.15 : 1;
return (
<div className="flex-1 min-w-0">
<div
className="text-[0.6875rem] font-medium mb-1.5 text-center"
style={{ color: "var(--muted-foreground)" }}
>
{label}
</div>
<svg
viewBox="0 0 160 120"
className="w-full rounded border"
style={{ borderColor: "var(--border)" }}
>
<rect width="160" height="120" fill={diffMode ? "#1a1a1a" : "#111"} />
{/* Nav bar */}
<rect
x="0"
y="0"
width="160"
height="16"
fill="#222"
opacity={dimOpacity}
/>
<rect
x="8"
y="5"
width="24"
height="6"
rx="1"
fill="#555"
opacity={dimOpacity}
/>
<rect
x="120"
y="5"
width="12"
height="6"
rx="1"
fill="#444"
opacity={dimOpacity}
/>
<rect
x="136"
y="5"
width="12"
height="6"
rx="1"
fill="#444"
opacity={dimOpacity}
/>
{/* Heading */}
<rect
x="20"
y="26"
width="80"
height="6"
rx="1"
fill="#666"
opacity={dimOpacity}
/>
{/* Subtext */}
<rect
x="30"
y="38"
width="60"
height="4"
rx="1"
fill="#444"
opacity={dimOpacity}
/>
{/* Input field */}
<rect
x="30"
y="52"
width="100"
height="14"
rx="2"
fill="#1a1a1a"
stroke="#333"
strokeWidth="0.5"
opacity={dimOpacity}
/>
{/* Button -- this is what changes */}
{diffMode ? (
<>
<rect
x="55"
y="76"
width="50"
height="14"
rx="2"
fill="#ef4444"
opacity="0.85"
/>
<rect
x="55"
y="76"
width="50"
height="14"
rx="2"
fill="none"
stroke="#ef4444"
strokeWidth="1.5"
strokeDasharray="3 2"
/>
</>
) : (
<rect
x="55"
y="76"
width="50"
height="14"
rx="2"
fill={buttonColor}
/>
)}
<text
x="80"
y="85.5"
textAnchor="middle"
fill="white"
fontSize="6"
fontFamily="system-ui, sans-serif"
opacity={diffMode ? 0.9 : 1}
>
Submit
</text>
{/* Footer line */}
<rect
x="40"
y="102"
width="80"
height="3"
rx="1"
fill="#333"
opacity={dimOpacity}
/>
</svg>
</div>
);
}
const snapshotDiffLines = [
" heading \"Sign Up\" [ref=e1]",
" text \"Create your account\" [ref=e2]",
"- textbox \"Email\" [ref=e3]",
"+ textbox \"Email\" [ref=e3]: \"test@example.com\"",
"- button \"Submit\" [ref=e4]",
"+ button \"Submit\" [ref=e4] [disabled]",
"+ status \"Sending...\" [ref=e7]",
" link \"Already have an account?\" [ref=e5]",
];
export function DiffDemo() {
return (
<div className="grid gap-8 my-8">
{/* Panel 1: Snapshot diff */}
<div>
<div
className="text-xs font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Verify an action changed the page
</div>
<Terminal>
<div className="opacity-60 mb-2">
<CommandLine>agent-browser snapshot -i</CommandLine>
<CommandLine>
agent-browser fill @e3 &quot;test@example.com&quot;
</CommandLine>
<CommandLine>agent-browser click @e4</CommandLine>
</div>
<div className="mb-3">
<CommandLine>agent-browser diff snapshot</CommandLine>
</div>
<div
className="border-t pt-3"
style={{ borderColor: "var(--border)" }}
>
{snapshotDiffLines.map((line, i) => (
<DiffLine key={i} line={line} />
))}
<div className="mt-2 opacity-60">
<span className="text-green-400">3</span> additions,{" "}
<span className="text-red-400">2</span> removals,{" "}
<span>3</span> unchanged
</div>
</div>
</Terminal>
</div>
{/* Panel 2: Screenshot diff */}
<div>
<div
className="text-xs font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Catch a visual regression
</div>
<Terminal>
<div className="mb-3">
<CommandLine>
agent-browser diff screenshot --baseline before-deploy.png
</CommandLine>
</div>
<div
className="border-t pt-3"
style={{ borderColor: "var(--border)" }}
>
<div className="text-red-400">
&#x2717; 2.37% pixels differ
</div>
<div className="opacity-50">
Diff image: ~/.agent-browser/tmp/diffs/diff-1708473621.png
</div>
<div className="opacity-50">
<span className="text-red-400">1,137</span> different /{" "}
48,000 total pixels
</div>
</div>
</Terminal>
<div className="flex gap-2 mt-3">
<PageMockup label="Baseline" buttonColor="#3b82f6" />
<PageMockup label="Current" buttonColor="#22c55e" />
<PageMockup label="Diff" buttonColor="#ef4444" diffMode />
</div>
</div>
</div>
);
}
-538
View File
@@ -1,538 +0,0 @@
"use client";
import {
useRef,
useEffect,
useState,
useCallback,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown";
import Link from "next/link";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
const DESKTOP_DEFAULT_WIDTH = 400;
const DESKTOP_MIN_WIDTH = 300;
const DESKTOP_MAX_WIDTH = 700;
function setCookie(name: string, value: string) {
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
}
const TOOL_LABELS: Record<
string,
{ label: string; pastLabel: string; argKey?: string }
> = {
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
};
function isToolPart(part: { type: string }): part is {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
} {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}
function getToolName(part: { type: string; toolName?: string }): string {
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
return part.type.replace(/^tool-/, "");
}
function ToolCallDisplay({
part,
}: {
part: {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
};
}) {
const toolName = getToolName(part);
const config = TOOL_LABELS[toolName] ?? {
label: toolName,
pastLabel: toolName,
};
const isDone = part.state === "output-available";
const isError = part.state === "output-error";
const isRunning = !isDone && !isError;
const displayLabel = isRunning ? config.label : config.pastLabel;
const args = (part.input ?? {}) as Record<string, unknown>;
const argValue = config.argKey ? args[config.argKey] : undefined;
const argPreview =
argValue != null
? String(argValue)
.replace(/^\/workspace\//, "/")
.replace(/\.md$/, "")
.replace(/\/index$/, "") || "/"
: "";
// Link to the docs page if it's a readFile path
const docsLink =
toolName === "readFile" && argPreview.startsWith("/") ? argPreview : null;
const argEl = argPreview ? (
docsLink ? (
<Link href={docsLink} className="truncate underline underline-offset-2">
{argPreview}
</Link>
) : (
<span className="truncate">{argPreview}</span>
)
) : null;
return (
<div className="text-xs py-0.5 min-w-0">
{isRunning ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span>
{argEl}
</span>
) : (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span>
{argEl}
{isError && <span className="text-destructive">failed</span>}
</span>
)}
</div>
);
}
const SUGGESTIONS = [
"What is agent-browser?",
"How do I install it?",
"What commands are available?",
"How do snapshots work?",
"How do I use CDP mode?",
];
export function DocsChat({
defaultOpen = false,
defaultWidth = DESKTOP_DEFAULT_WIDTH,
}: {
defaultOpen?: boolean;
defaultWidth?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
const [input, setInput] = useState("");
const [isDesktop, setIsDesktop] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
const [desktopWidth, setDesktopWidth] = useState(
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const restoredRef = useRef(false);
const isDraggingRef = useRef(false);
const { messages, sendMessage, status, setMessages, error } = useChat({
transport,
});
const isLoading = status === "streaming" || status === "submitted";
const showMessages = messages.length > 0 || !!error || isLoading;
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
useEffect(() => {
const mq = window.matchMedia("(min-width: 640px)");
setIsDesktop(mq.matches);
setHasMounted(true);
// If on mobile but sidebar was open from cookie, close it
if (!mq.matches && defaultOpen) {
setOpen(false);
}
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
useEffect(() => {
if (hasMounted) {
setCookie("docs-chat-open", String(open));
}
}, [open, hasMounted]);
// Push page content on desktop when pane is open.
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
// instead of appearing right next to the sidebar's scrollbar.
useEffect(() => {
const body = document.body;
if (isDesktop && open) {
body.style.paddingRight = `${desktopWidth}px`;
if (!isDraggingRef.current) {
body.style.transition = "padding-right 150ms ease";
}
} else if (isDesktop) {
body.style.paddingRight = "0px";
body.style.transition = "padding-right 150ms ease";
}
return () => {
body.style.paddingRight = "0px";
body.style.transition = "";
};
}, [isDesktop, open, desktopWidth]);
// Resize handle drag
const handleResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
document.documentElement.style.transition = "none";
const startX = e.clientX;
const startWidth = desktopWidth;
const onPointerMove = (ev: globalThis.PointerEvent) => {
const delta = startX - ev.clientX;
const newWidth = Math.min(
DESKTOP_MAX_WIDTH,
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
);
setDesktopWidth(newWidth);
};
const onPointerUp = () => {
isDraggingRef.current = false;
document.documentElement.style.transition = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
},
[desktopWidth],
);
// Persist width to cookie
useEffect(() => {
setCookie("docs-chat-width", String(desktopWidth));
}, [desktopWidth]);
// Restore messages from sessionStorage on mount
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
}
}
} catch {
// ignore parse errors
}
}, [setMessages]);
// Save completed messages to sessionStorage
useEffect(() => {
if (!restoredRef.current) return;
if (isLoading) return;
if (messages.length === 0) {
sessionStorage.removeItem(STORAGE_KEY);
return;
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch {
// ignore quota errors
}
}, [messages, isLoading]);
// Cmd+K to open sidebar and focus prompt, Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => {
if (!prev) {
setTimeout(() => inputRef.current?.focus(), 200);
}
return !prev;
});
}
if (e.key === "Escape" && open && isDesktop) {
setOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, isDesktop]);
// Auto-focus input when opened
useEffect(() => {
if (open) {
const timer = setTimeout(() => inputRef.current?.focus(), 200);
return () => clearTimeout(timer);
}
}, [open]);
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
const el = messagesScrollRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}, [messages, error]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
},
[input, isLoading, sendMessage],
);
const handleClear = useCallback(() => {
setMessages([]);
sessionStorage.removeItem(STORAGE_KEY);
}, [setMessages]);
const hasVisibleContent = (
parts: (typeof messages)[number]["parts"],
): boolean => {
return parts.some(
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
);
};
// Shared chat panel content used by both desktop and mobile
const chatPanel = (
<>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
<span className="text-sm font-medium">agent-browser Docs</span>
<div className="flex items-center gap-3">
{showMessages && (
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
)}
<button
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close panel"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/* Content: suggestions or messages */}
{showMessages ? (
<div
ref={messagesScrollRef}
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
>
{messages.map((message) => {
if (!hasVisibleContent(message.parts)) return null;
return (
<div key={message.id}>
{message.role === "user" ? (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{message.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
)
.map((p) => p.text)
.join("")}
</div>
) : (
<div className="space-y-2">
{message.parts.map((part, i) => {
if (part.type === "text" && part.text) {
return (
<div
key={i}
className="docs-chat-content text-sm text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
>
<Streamdown>{part.text}</Streamdown>
</div>
);
}
if (isToolPart(part)) {
return (
<ToolCallDisplay key={part.toolCallId} part={part} />
);
}
return null;
})}
</div>
)}
</div>
);
})}
{error && (
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
{(() => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || parsed.error || error.message;
} catch {
return (
error.message || "Something went wrong. Please try again."
);
}
})()}
</div>
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex flex-wrap gap-2 p-4">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => {
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows={1}
enterKeyHint="send"
placeholder="Ask a question..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
aria-label="Send message"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
</>
);
return (
<>
{/* Ask AI trigger button */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity text-sm font-medium"
aria-label="Ask AI"
>
Ask AI
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
<span>&#8984;</span>K
</kbd>
</button>
)}
{/* Desktop: resizable side pane -- always rendered, hidden on mobile via CSS */}
<aside
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
style={{ width: desktopWidth }}
aria-hidden={!open}
>
{/* Resize handle */}
<div
onPointerDown={handleResizePointerDown}
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
/>
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
</aside>
{/* Mobile: Sheet overlay/drawer -- only after mount to avoid flash on desktop */}
{hasMounted && !isDesktop && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
showCloseButton={false}
overlayClassName="bg-background!"
className="inset-0! w-full! h-full! max-w-none! p-0 flex flex-col"
style={{ backgroundColor: "var(--background)", opacity: 1 }}
>
<SheetTitle className="sr-only">AI Chat</SheetTitle>
{chatPanel}
</SheetContent>
</Sheet>
)}
</>
);
}
-81
View File
@@ -1,81 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import { navigation, allDocsPages } from "@/lib/docs-navigation";
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const currentPage = useMemo(() => {
const page = allDocsPages.find((p) => p.href === pathname);
return page ?? allDocsPages[0];
}, [pathname]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger className="lg:hidden sticky top-14 z-40 w-full px-6 py-3 bg-background/80 backdrop-blur-sm border-b border-border flex items-center justify-between focus:outline-none">
<div className="text-sm font-medium">{currentPage?.name}</div>
<div className="w-8 h-8 flex items-center justify-center">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</div>
</SheetTrigger>
<SheetContent side="left" showCloseButton={false} className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
<nav className="space-y-6">
{navigation.map((section, sectionIndex) => (
<div key={section.title ?? sectionIndex}>
{section.title && (
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</SheetContent>
</Sheet>
);
}
-44
View File
@@ -1,44 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { navigation } from "@/lib/docs-navigation";
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="space-y-6 pb-8">
{navigation.map((section, sectionIndex) => (
<div key={section.title ?? sectionIndex}>
{section.title && (
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"text-sm transition-colors block py-1",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
{item.name}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}

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