Compare commits

...
Author SHA1 Message Date
leeguooooo 6f4e63ba91 chore(release): bump to 0.27.0-fork.9 — upstream sync + CDP consent fix
Upstream cherry-picks (onto v0.27.0 base):
- security: same-origin stream command relay (#1355)
- feat: hide scrollbars in headless screenshots (#1396)
- chore: pnpm minimum release age + node pinning (#1377, fork-adapted)

Fork fixes:
- fix(connect): stop remote-debugging consent storm — is_connection_alive no
  longer tears down an externally-attached browser on a transient liveness
  timeout (was an endless prompt loop / browser freeze)
- fix(connect): single consenting WebSocket — drop the throwaway verify probe
  so the user's one "Allow remote debugging?" click sticks to the real
  connection
2026-06-01 12:26:16 +09:00
leeguooooo 98622a7415 fix(connect): single consenting WebSocket — drop throwaway verify probe
auto-connect resolved the DevToolsActivePort URL by first opening a
verification WebSocket (verify_ws_endpoint: connect, Browser.getVersion,
close) and only then opening the real connection. On Chrome 136+ the
"Allow remote debugging?" consent is granted per-connection, so the user's
single Allow click was consumed by the throwaway probe and the real
connection (opened afterwards) asked again — surfacing as repeated prompts
or a hung command after the user had already clicked Allow.

resolve_cdp_from_active_port now gates the direct DevToolsActivePort URL on
a consent-free TCP liveness check (tcp_port_alive) instead of a WebSocket
probe, so the real connection is the single WebSocket the user consents to.
A bare TCP connect does not trigger the consent flow (that fires on the CDP
upgrade), and the real connect_async has no client-side timeout, so it waits
for the user to click Allow at their own pace. verify_ws_endpoint removed;
discovery-order tests updated, plus a guard test that resolution opens no
WebSocket.

Verified live: single prompt on a real Chrome attach, then open + eval +
scroll x2 + eval with zero re-prompts and no freeze.
2026-06-01 12:20:22 +09:00
leeguooooo 3d032f9e88 fix(connect): stop remote-debugging consent storm on transient liveness timeout
The daemon re-validates the CDP connection before every browsing command via
is_connection_alive() (Browser.getVersion, 3s timeout). It treated any
timeout-or-error as "dead" and tore the connection down + reconnected.

For an externally-attached browser (the stealth fork's default — the user's
real Chrome), a timed-out probe is almost always Chrome being briefly busy or
showing the Chrome 136+ "Allow remote debugging?" consent modal, which blocks
CDP responses until the user clicks Allow. Tearing the already-consented
connection down forces a reconnect that re-pops the consent prompt — repeated
on every command this becomes an endless prompt loop, and the close +
multiple new /devtools/browser WS probes storm Chrome into a freeze.

Fix: distinguish the probe outcome.
- Responded      -> alive
- TransportError -> dead (WS closed/reset; user closing Chrome lands here too,
                    so zombie-socket detection is preserved)
- TimedOut       -> alive for an external attach (don't tear down a consented
                    connection on transient slowness); dead for a browser we
                    launched ourselves (a real hang worth reconnecting, and no
                    consent modal in play).

Extracted the verdict into a pure connection_alive_from_probe() with unit
tests covering all outcomes. No behavior change for locally-launched browsers.
2026-06-01 11:36:00 +09:00
leeguooooo d027659571 feat(screenshot): hide scrollbars in headless screenshots (cherry-pick b4f2f37)
Cherry-picks upstream agent-browser #1396. Adds a configurable
--hide-scrollbars flag (AGENT_BROWSER_HIDE_SCROLLBARS env, hideScrollbars
config key, default true) that appends Chrome's --hide-scrollbars launch arg
for headless (non-extension) launches so native scrollbars aren't painted into
screenshots. Plumbed through flags.rs, connection.rs, main.rs, native/actions.rs
and native/cdp/chrome.rs; help text in output.rs + skill-data.

Fork adaptation:
- the arg lands in the headless && !has_extensions block, separate from the
  stealth base args — no interaction with anti-detection.
- dropped upstream docs/, agent-browser.schema.json and README hunks (removed
  or rewritten in this fork).

Verified: cargo check --tests passes.
2026-06-01 10:35:20 +09:00
leeguooooo 44b6218ef9 chore(ci): adopt upstream pnpm release-age + node pinning (cherry-pick 4ad2848)
Cherry-picks upstream agent-browser #1377 (chore: enforce pnpm minimum
release age), adapted for the fork:

- add .node-version (24); workflows read node-version-file instead of inline
- pin packageManager pnpm@11.1.3; drop hard-coded pnpm/action-setup versions
- pnpm-workspace.yaml: add minimumReleaseAge (48h supply-chain cooldown) +
  allowBuilds allowlist, keeping our trimmed packages list (no packages/*, docs)

Deliberately dropped from upstream:
- engines.node >=24 / engines.pnpm >=11 — would impose a Node 24 floor on
  end-users of the published agent-browser-stealth CLI (a compiled binary that
  doesn't need it). packageManager + .node-version cover dev/CI pinning.
- docs/ and README hunks — those paths are removed/rewritten in this fork.
2026-06-01 10:34:27 +09:00
Chris TateandMuhtasham e93acc68f8 Require same-origin stream commands (#1355)
* Require same-origin stream commands

Protect the per-session command relay from browser-originated cross-origin requests while preserving same-origin dashboard access.

Co-authored-by: Muhtasham <20128202+Muhtasham@users.noreply.github.com>

* Harden stream command origin checks

Require command relay requests to come from loopback same-origin metadata and prevent request bodies from spoofing security headers.

Co-authored-by: Muhtasham <20128202+Muhtasham@users.noreply.github.com>

---------

Co-authored-by: Muhtasham <20128202+Muhtasham@users.noreply.github.com>
2026-06-01 10:32:44 +09:00
leeguooooo d2a33cc005 fix(scripts): serialize all-platforms build + per-pid wait checks
Two related bugs that conspired to ship stale linux binaries on
0.27.0-fork.5/.7/.8 (caught only by manually grepping the embedded
version string each release):

1. build:all-platforms used `(... & npm run build:linux & wait)`.
   The bare `wait` waits for ALL children but exits with the LAST
   waited child's status, not each individually. So if linux fell
   over and windows succeeded last, the script reported success.
   Worse, when both processes shared cli/target/ and fought over
   cargo's filesystem locks, one would silently bail out and the
   missing binary just stayed at the previous release's bytes.

   Now serial: `npm run build:linux && npm run build:windows &&
   npm run build:macos`. Costs ~3 extra minutes wall-clock vs.
   parallel; trades latency for "every release ships what it says".

2. build:macos had the same `(... & ... & wait)` parallel pattern
   for arm64 + x64 cross-compiles. Native cargo builds against the
   same target/ dir share even more state than the docker'd Linux
   build did, so the failure mode is the same. Now uses explicit
   `PID1=$!; PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1`
   so both must succeed.

Companion to the docker-compose $$ fix in 947d150 (which fixed the
*inside-container* wait+cp eating shell vars). This one fixes the
*outer* npm-script layer.
2026-05-09 12:58:39 +09:00
leeguooooo c26afbaba6 chore(release): bump to 0.27.0-fork.8 — auto-retry transient occlusion 2026-05-09 12:39:14 +09:00
leeguooooo ffb386e3af feat(click): auto-retry on transient occlusion before erroring
fork.7 caught the X mask-overlay race correctly but reported it to
the user verbatim — every transient overlay (modal backdrop, focus
ring, click-outside mask, sticky banner) became an error the user
had to wrap in their own retry loop. Most of these clear within a
frame or two on their own.

Now `verify_click_target` retries the elementFromPoint probe a few
times (default 3 × 200ms = 600ms total grace period) before failing.
Real-world overlays that blink in for a render cycle clear during
the first retry; persistent overlays still surface as errors with
the same actionable message — just qualified with "still occluded
after N retries / Mms" so the user knows we tried.

Tunable:
  AGENT_BROWSER_OCCLUSION_RETRIES         (default 3, 0 disables)
  AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS  (default 200)

DOM.resolveNode is called once outside the loop — backendNodeId is
stable across renders, only the element under (x, y) changes when
overlays flicker. Each probe is still capped at 500ms so a stuck
Runtime.callFunctionOn can't stall a click for longer than the user
expects.
2026-05-09 12:39:03 +09:00
leeguooooo 947d150561 fix(docker): escape \$ as \$\$ so docker compose doesn't eat shell vars
Real bug behind 0.27.0-fork.5 and fork.7 shipping stale linux binaries.
Docker compose interpolates \${VAR} (and \$VAR) at YAML parse time
against the host shell — including inside `command:` blocks. So:

  PID1=\$!                ← compose sees \$! → host has no `!` var → ""
  wait \$PID1 ...         ← compose sees \$PID1 → "" → becomes `wait `
  SRC="...\$TARGET..."    ← \$TARGET still works (set in `environment:`)
  cp "\$SRC" "..."        ← \$SRC eaten → empty → cp errors silently

Result: the per-PID error check I added in dbf272c never fired
because both lines were `wait` (no args) — which waits for ALL
children and exits with the LAST one's status, not each individually.
A failing arm64 build couldn't fail the script.

Fix: escape every script-local \$ as \$\$. Docker compose translates
\$\$ → literal \$ when materializing the command for the container,
and the in-container shell then expands \$VAR correctly.

Verified by `docker compose config` showing the resolved command
contains \$\$PID1 / \$\$SRC etc (which becomes \$PID1 / \$SRC in the
container's bash).
2026-05-09 11:07:01 +09:00
leeguooooo 06a29251a2 chore(release): bump to 0.27.0-fork.7 — click occlusion guard 2026-05-09 10:49:39 +09:00
leeguooooo 0eacec9b9f fix(click): occlusion check via document.elementFromPoint before dispatch
Closes the "modal silently closes when clicking 'Add post' on a thread"
bug. Verified root cause via instrumented page-side click logger:

  click @e31 (aria-label="Add post" at button (1034, 285))
  → mouse event dispatched to (1045, 296)
  → document.elementFromPoint(1045, 296) returned:
       DIV[testid="mask"], bounds (0,0,1746x934)
  → X interpreted as "click outside modal" → close + nav to /home

The cached coordinates were correct. Between snapshot and click, X
laid a transient full-viewport mask over the modal (their own
"click-outside-to-close" overlay). stealth dispatched the click
without checking what was actually at that pixel — the overlay
intercepted it.

Fix: just before returning (x, y) from resolve_element_center for
ref-based interactions, run a Runtime.callFunctionOn against the
ref's resolved element with `function(x, y) { return this.contains(
document.elementFromPoint(x, y)) || that.contains(this) ? null :
{...occluder details...}; }`. If the element at the point isn't us
(or our descendant — clicking the SVG icon inside a button is fine
— or our ancestor), we fail with a specific message:

  Ref @e31 is occluded by DIV[testid=mask] at the click point.
  A transient overlay (modal backdrop, mask, sticky banner, etc.)
  appeared between snapshot and click. Wait for it to clear or
  re-snapshot, then retry.

So instead of silently submitting an entire thread or nuking the
user's modal, agent gets a parseable error and can wait + retry.

Tight 500ms timeout per CDP call (matching the verify_ref_identity
defensive guard from fork.6) so a stuck DOM.resolveNode can't
re-introduce the multi-minute hang we just fixed. On any timeout
or error in the guard itself, fall through and let the click
proceed — strictly no worse than the unguarded code path.

Disable with AGENT_BROWSER_VERIFY_CLICK_TARGET=0.
2026-05-09 10:49:27 +09:00
leeguooooo 7159012173 chore(release): bump to 0.27.0-fork.6 — defensive-guard timeouts + accurate CDP tip 2026-05-09 10:04:25 +09:00
leeguooooo 1b3d41e579 fix(timeout): cap defensive CDP guards so click can't hang multi-minute
Reported: a single `click @ref` could hang 5+ minutes, with multiple
queued click invocations adding up to 7+ minutes — worst case 30s
timeout × 3 CDP calls × N parallel processes:

  - verify_ref_identity (Accessibility.getPartialAXTree)  →  default 30s
  - resolveNode / getBoxModel                              →  default 30s
  - wait_for_paint_settled (Runtime.evaluate awaitPromise) →  default 30s

The latter two are best-effort defenses added in fork.3-5 to fix SPA
race / DOM-reuse bugs. They should never block a real click for
30s — the unguarded code path was always faster than the guarded
path-that-hangs.

  - verify_ref_identity   capped at 1s   (skips check on timeout)
  - wait_for_paint_settled capped at 500ms (skips wait on timeout)

Both skip-on-timeout intentionally: the worst case is the click
behaves like fork.2 (race-prone but fast), which is strictly better
than the user pkilling stuck processes.

Also rewrites the misleading "Chrome 144+ chrome://inspect tip" in
the auto-connect failure message — the toggle exposes target
discovery only, not the /json/version HTTP API the auto-connect
flow expects (verified by user: lsof shows :9222 listening but
curl /json/version returns 404).
2026-05-09 10:04:14 +09:00
leeguooooo dbf272ced7 fix(docker): catch parallel-build failures + stop using glob in cp
Two latent bugs in the release pipeline that conspired to ship a stale
linux-x64 binary in 0.27.0-fork.5 (only caught by manually grepping
the embedded version string):

1. build-linux ran x64 and arm64 in parallel and used a single
   `wait $PID1 $PID2` to join them. That command waits for both, but
   its exit code is the LAST waited pid only — so if x64 silently
   broke and arm64 succeeded, the outer script exited 0 and shipped
   whatever was already in /output from the previous release. Now we
   wait on each pid individually and exit 1 on either failure.

2. build-single's cp used `agent-browser*` which globs to BOTH the
   binary and its `.d` dependency file. When two sources are passed,
   cp requires the destination to be a directory. We weren't, so cp
   exited non-zero with "Not a directory" and the build script
   shrugged it off because the next line was `chmod ... || true`.
   Now we resolve a single explicit source path.
2026-05-09 04:30:20 +09:00
leeguooooo 64140879d5 chore(release): bump to 0.27.0-fork.5 — attach-mode UX + zombie-CDP probe + wait @ref 2026-05-09 04:11:09 +09:00
leeguooooo d3bfd76c96 fix(connect): liveness probe + wait @ref support
Two changes that pair with each other:

1. connect_auto_with_fresh_tab now does a Runtime.evaluate "1"
   round-trip after creating the fresh tab. This catches the zombie
   CDP socket case (process alive, websocket dead) where every step
   up to that point reports success but the next user command would
   silently no-op against a dead session. Failing here lets the
   caller surface a proper "CDP session unresponsive" error instead
   of returning Ok and letting `agent-browser open URL` exit 0 with
   a still-blank tab.

2. handle_wait now recognizes @ref selectors (e.g. `wait @e8 --gone`).
   It polls resolve_element_object_id, which already runs the
   verify_ref_identity check from 007fd1b — so:
     - `wait @e8`             succeeds while the original element is
                              still mounted with its snapshot role+name
     - `wait @e8 --gone`      succeeds when the ref's identity changes
                              (modal closed, button re-textified, etc.)
   This gives users the "assert modal still open" primitive that
   prior versions could only approximate with screenshots.
2026-05-09 04:10:48 +09:00
leeguooooo 47dfe760be fix(cli): better message when only --headed is ignored in attach mode
In CDP-attach mode (the default since 0.24.0-fork.1), --headed has no
effect — the user's existing Chrome is already visible, and the
generic "use 'agent-browser close' first to restart" advice doesn't
help (the new daemon attaches right back). Explicitly say --headed is
moot and point to --launch as the actual escape hatch.

Other ignored flags (--profile, --proxy, etc.) keep the existing
"close + reopen" message because for those it IS the right advice.
2026-05-09 04:10:46 +09:00
leeguooooo 0db6604105 chore(release): bump to 0.27.0-fork.4 — ref identity guard 2026-05-09 03:26:48 +09:00
leeguooooo 007fd1b27f fix(refs): verify identity before using cached backendNodeId
Closes the "click @e20 hits the sibling element" bug. Real-world
example: snapshot shows @e20=[button "Add post"] next to
@e17=[button "Post all"]. By the time you click @e20, React has
re-rendered — and React often re-uses the same <button> DOM node
across renders, just updating its accessible name. The cached
backendNodeId still resolves to a real, well-positioned node, so
the click lands cleanly. It just lands on what is now the "Post all"
button, silently submitting the entire thread instead of adding a
draft row.

Before every ref-based interaction (click / fill / type / hover /
select / drag — anything routing through resolve_element_center or
resolve_element_object_id), call Accessibility.getPartialAXTree for
the cached backendNodeId and check role + name still match the
snapshot entry. On mismatch, abort with an error that names both
labels:

  Ref @e20 no longer matches its snapshot. Was [button "Add post"],
  now [button "Post all"].
  ...Take a fresh snapshot, then re-target.

If the node is gone (CDP fails / no AX node), we silently fall
through to the existing "find by role+name" recovery path, so this
guard never makes a working flow worse.

Adds one CDP roundtrip per ref interaction (~5–20ms). Disable with
AGENT_BROWSER_VERIFY_REF=0 if you control the page lifecycle and
need the latency back.
2026-05-09 03:26:20 +09:00
leeguooooo 3d1132af90 chore(release): bump to 0.27.0-fork.3 — click paint-settle + wait --gone 2026-05-09 02:45:45 +09:00
leeguooooo 90ba44cd38 feat(wait): add --gone / --hidden flags so users can fail fast on closed UIs
Pairs with the click paint-settle fix: even with that, a thread builder
that clicks "Add post" can race a misbehaving handler that closes the
parent modal instead of mounting the next textbox. To make that case
observable instead of silently corrupting the next inserttext, you can
now write:

  click @add-post
  wait .modal --gone --timeout 2000   # asserts modal stays mounted
  inserttext "tweet 3"

If the modal vanished, `wait --gone` succeeds — flip the assertion to
`wait .modal` (default visible) to fail-fast on disappearance.

Implementation just sets `state: "detached"` (or "hidden") on the wait
command — daemon-side `wait_for_selector` already supported these
states; only the CLI parser was missing the user-facing flag.

Also accepts `--detached` as alias for `--gone` to match the daemon's
internal vocabulary.
2026-05-09 02:45:34 +09:00
leeguooooo 52f8ead0f2 fix(click): wait for paint to settle so SPA renders complete before next command
Closes a real-world race that broke X multi-tweet thread composition
(and similar SPA flows): clicking "Add post" returned immediately,
inserttext fired before React had committed the new textarea, the
keystroke landed on the dialog wrapper, and X interpreted the stray
input as a request to dismiss the modal.

After mouseReleased we now wait for two requestAnimationFrame ticks
plus a microtask boundary (~33ms at 60fps, bounded). That's enough
for React/Vue/Svelte to commit any state update scheduled by the
click handler. Errors during the wait are swallowed — a click never
fails because of post-processing.

Opt out for perf-sensitive scripts that don't drive SPA UIs:
  AGENT_BROWSER_CLICK_WAIT_STABLE=0
2026-05-09 02:45:21 +09:00
leeguooooo ffa5bd63f6 chore(release): bump to 0.27.0-fork.2 — find error UX + URL preservation 2026-05-09 01:41:25 +09:00
leeguooooo 926f08203c chore: regenerate pnpm-lock.yaml after dashboard removal
The previous lockfile had ~11k lines of transitive deps for
packages/dashboard which we deleted in 86c4cff. Re-running pnpm install
shrinks it to ~24 lines (just husky for git hooks).
2026-05-09 01:41:12 +09:00
leeguooooo 2b1a3c308a feat(daemon): preserve URL across version-mismatch restart
Before: after `npm i -g` upgrade, the next agent-browser command would
detect daemon version mismatch, kill the old daemon, spawn a fresh one,
and connect to a brand-new about:blank tab. The user's previous
navigation state was silently lost — `get url` returned about:blank
even though the user's Chrome was still on the same page.

Now: before killing the old daemon, the CLI synchronously asks it for
its current URL via the existing socket. If non-empty and not
about:blank, it's persisted to a `.restore-url` sidecar in the socket
dir. After the new daemon spawns and auto-connects, it reads the
sidecar (read-and-delete), navigates the fresh tab to the saved URL,
and prints `⚠ Restored previous URL: <url>`.

Manual `agent-browser close` does NOT write the sidecar, so a clean
shutdown won't trigger surprise navigation. The sidecar is consumed on
read regardless of whether navigation succeeded, so a stale entry
can't haunt later auto-launches.
2026-05-09 01:41:07 +09:00
leeguooooo 6c556e519d feat(parse): friendly error when find has --flag where action verb expected
Before, `agent-browser find role button --name Submit` errored at the
daemon side with the cryptic `Unknown subaction: --name`. Now it errors
at parse time with the offending flag echoed back, the list of valid
actions (click, fill, check, hover, text), and a "Did you mean" hint
showing where to put the action verb.

Backwards compat: `find role button` (no flags, no action) still
defaults to click — only `--xxx` in action position errors.
2026-05-09 01:40:56 +09:00
leeguooooo 9e48b0757c fix(package): drop ./ prefix from bin entries
npm 10+ strips bin paths starting with ./ as invalid, leaving the
package with no executable entries (so `npm i -g` doesn't put any
binary on PATH). Match the upstream form `bin/agent-browser.js`.
2026-05-09 00:52:36 +09:00
24 changed files with 1654 additions and 11304 deletions
+7 -4
View File
@@ -15,6 +15,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Check version sync
run: node scripts/check-version-sync.js
@@ -54,12 +59,10 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
node-version-file: .node-version
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install --filter dashboard
@@ -209,7 +212,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
node-version-file: .node-version
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
+7 -6
View File
@@ -26,6 +26,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Compare package.json version to npm and check GitHub release
id: check
run: |
@@ -109,13 +114,11 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version-file: .node-version
cache: pnpm
- name: Install npm dependencies
@@ -202,13 +205,11 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version-file: .node-version
cache: pnpm
registry-url: 'https://registry.npmjs.org'
+1
View File
@@ -0,0 +1 @@
24
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.1"
version = "0.27.0-fork.9"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.1"
version = "0.27.0-fork.9"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+156 -4
View File
@@ -614,17 +614,44 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
return Ok(cmd);
}
// --gone / --hidden: wait for an element to leave the DOM or
// become invisible. Useful after a click that's supposed to
// close a dialog, so the next command fails fast instead of
// racing into a half-rendered UI.
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
Some("detached")
} else if rest.iter().any(|&s| s == "--hidden") {
Some("hidden")
} else {
None
};
// Default: selector or timeout
if let Some(arg) = rest.first() {
// First non-flag positional is selector or numeric timeout
let positional = rest.iter().find(|&&s| !s.starts_with("--"));
let timeout_ms = rest
.iter()
.position(|&s| s == "--timeout")
.and_then(|idx| rest.get(idx + 1))
.and_then(|s| s.parse::<u64>().ok());
if let Some(arg) = positional {
if let Ok(timeout) = arg.parse::<u64>() {
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
} else {
Ok(json!({ "id": id, "action": "wait", "selector": arg }))
let mut cmd = json!({ "id": id, "action": "wait", "selector": arg });
if let Some(state) = state_override {
cmd["state"] = json!(state);
}
if let Some(t) = timeout_ms {
cmd["timeout"] = json!(t);
}
Ok(cmd)
}
} else {
Err(ParseError::MissingArguments {
context: "wait".to_string(),
usage: "wait <selector|ms|--url|--load|--fn|--text>",
usage: "wait <selector|ms> [--gone|--hidden] [--timeout ms]",
})
}
}
@@ -2180,7 +2207,33 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
_ => "find <locator> <value> [action] [text]",
},
})?;
let subaction = rest.get(2).unwrap_or(&"click");
let raw_subaction = rest.get(2).copied();
if let Some(s) = raw_subaction {
if s.starts_with("--") {
return Err(ParseError::InvalidValue {
message: format!(
"Missing action verb for `find {locator}` (got `{flag}` where action was expected).\n\
Valid actions: click, fill, check, hover, text\n\
Did you mean: agent-browser find {locator} <value> click {flag} ...?",
locator = locator,
flag = s,
),
usage: match *locator {
"role" => "find role <role> <action> [--name <name>] [--exact]",
"text" => "find text <text> <action> [--exact]",
"label" => "find label <label> <action> [text] [--exact]",
"placeholder" => "find placeholder <text> <action> [text] [--exact]",
"alt" => "find alt <text> <action> [--exact]",
"title" => "find title <text> <action> [--exact]",
"testid" => "find testid <id> <action> [text]",
"first" => "find first <selector> <action> [text]",
"last" => "find last <selector> <action> [text]",
_ => "find <locator> <value> <action> [text]",
},
});
}
}
let subaction = raw_subaction.unwrap_or("click");
let mut name: Option<&str> = None;
let mut exact = false;
let mut fill_parts: Vec<&str> = Vec::new();
@@ -2698,6 +2751,7 @@ mod tests {
provider: None,
ignore_https_errors: false,
allow_file_access: false,
hide_scrollbars: true,
device: None,
auto_connect: false,
force_launch: false,
@@ -2713,6 +2767,7 @@ mod tests {
cli_proxy: false,
cli_proxy_bypass: false,
cli_allow_file_access: false,
cli_hide_scrollbars: false,
cli_annotate: false,
cli_download_path: false,
cli_headed: false,
@@ -5105,4 +5160,101 @@ mod tests {
let cmd = parse_command(&args("batch"), &default_flags()).unwrap();
assert!(cmd.get("commands").is_none());
}
// === parse_find: friendly error when action verb is missing ===
#[test]
fn test_find_role_missing_action_verb_with_name_flag() {
let err = parse_command(
&args("find role button --name Submit"),
&default_flags(),
)
.unwrap_err();
let msg = err.format();
assert!(
msg.contains("Missing action verb"),
"expected 'Missing action verb' in error, got: {msg}"
);
assert!(
msg.contains("Did you mean"),
"expected 'Did you mean' suggestion, got: {msg}"
);
assert!(
msg.contains("--name"),
"error should echo the offending flag back, got: {msg}"
);
}
#[test]
fn test_find_testid_missing_action_verb_with_exact_flag() {
let err = parse_command(
&args("find testid foo --exact"),
&default_flags(),
)
.unwrap_err();
assert!(err.format().contains("Missing action verb"));
}
#[test]
fn test_find_role_with_action_still_works() {
let cmd = parse_command(
&args("find role button click --name Submit"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "getbyrole");
assert_eq!(cmd["subaction"], "click");
assert_eq!(cmd["name"], "Submit");
}
#[test]
fn test_find_role_default_subaction_click_when_no_action() {
// Backwards compat: `find role button` (no flags, no action) keeps
// defaulting to click — only `--xxx` in action position errors.
let cmd = parse_command(&args("find role button"), &default_flags()).unwrap();
assert_eq!(cmd["subaction"], "click");
}
// === wait --gone / --hidden ===
#[test]
fn test_wait_selector_default_visible() {
let cmd = parse_command(&args("wait .toast"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "wait");
assert_eq!(cmd["selector"], ".toast");
assert!(cmd.get("state").is_none(), "default state stays implicit");
}
#[test]
fn test_wait_selector_gone_sets_detached_state() {
let cmd = parse_command(&args("wait .toast --gone"), &default_flags()).unwrap();
assert_eq!(cmd["selector"], ".toast");
assert_eq!(cmd["state"], "detached");
}
#[test]
fn test_wait_selector_hidden_sets_hidden_state() {
let cmd = parse_command(&args("wait .toast --hidden"), &default_flags()).unwrap();
assert_eq!(cmd["state"], "hidden");
}
#[test]
fn test_wait_gone_with_timeout() {
let cmd = parse_command(
&args("wait .modal --gone --timeout 2000"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["selector"], ".modal");
assert_eq!(cmd["state"], "detached");
assert_eq!(cmd["timeout"], 2000);
}
#[test]
fn test_wait_numeric_timeout_still_works() {
// `wait 500` keeps meaning "sleep 500ms", not "wait for selector 500"
let cmd = parse_command(&args("wait 500"), &default_flags()).unwrap();
assert_eq!(cmd["timeout"], 500);
assert!(cmd.get("selector").is_none());
}
}
+46
View File
@@ -127,6 +127,15 @@ fn get_version_path(session: &str) -> PathBuf {
get_socket_dir().join(format!("{}.version", session))
}
/// Path to the sidecar file that records the URL the previous daemon was on,
/// used to restore navigation after a version-mismatch restart. Only written
/// when the version-mismatch branch fires; cleared after the new daemon
/// reads it. Manual `close` does not write this file, so a clean shutdown
/// won't trigger surprise navigation.
pub fn get_restore_url_path(session: &str) -> PathBuf {
get_socket_dir().join(format!("{}.restore-url", session))
}
/// Clean up stale socket and PID files for a session
pub fn cleanup_stale_files(session: &str) {
let pid_path = get_pid_path(session);
@@ -135,6 +144,10 @@ pub fn cleanup_stale_files(session: &str) {
let _ = fs::remove_file(&version_path);
let stream_path = get_socket_dir().join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
// Note: the .restore-url sidecar is intentionally NOT removed here —
// it lives across the brief window between killing the old daemon
// and the new daemon reading it back. The new daemon deletes it after
// restoring (see actions::auto_launch).
#[cfg(unix)]
{
@@ -399,6 +412,7 @@ pub struct DaemonOptions<'a> {
pub proxy_password: Option<&'a str>,
pub ignore_https_errors: bool,
pub allow_file_access: bool,
pub hide_scrollbars: bool,
pub profile: Option<&'a str>,
pub state: Option<&'a str>,
pub provider: Option<&'a str>,
@@ -463,6 +477,10 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
if opts.allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
cmd.env(
"AGENT_BROWSER_HIDE_SCROLLBARS",
if opts.hide_scrollbars { "1" } else { "0" },
);
if let Some(prof) = opts.profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
@@ -527,6 +545,24 @@ fn daemon_version_matches(session: &str) -> bool {
}
}
/// One-shot socket query for the running daemon's current URL.
/// Returns None on any kind of failure — caller must treat as best-effort.
fn query_current_url(session: &str) -> Option<String> {
let cmd = serde_json::json!({
"id": format!("restore-url-probe-{}", std::process::id()),
"action": "url",
});
let resp = send_command_once(&cmd, session).ok()?;
if !resp.success {
return None;
}
resp.data
.as_ref()
.and_then(|d| d.get("url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Kill a running daemon by reading its PID file and sending a kill signal.
fn kill_stale_daemon(session: &str) {
// Remove the socket first so no new connections reach the old daemon
@@ -592,6 +628,16 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
"{} Daemon version mismatch detected, restarting...",
crate::color::warning_indicator()
);
// Best-effort: ask the old daemon for its current URL so the
// new daemon can restore navigation after auto-connect. If the
// query fails (already shutting down, no browser, etc.) we
// silently skip — the user just sees about:blank as before.
if let Some(url) = query_current_url(session) {
if !url.is_empty() && url != "about:blank" {
let path = get_restore_url_path(session);
let _ = fs::write(&path, &url);
}
}
kill_stale_daemon(session);
// Fall through to spawn a new daemon below
} else {
+1
View File
@@ -65,6 +65,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
proxy_password: None,
ignore_https_errors: false,
allow_file_access: false,
hide_scrollbars: true,
profile: None,
state: None,
provider: None,
+56 -1
View File
@@ -70,6 +70,7 @@ pub struct Config {
pub user_agent: Option<String>,
pub provider: Option<String>,
pub device: Option<String>,
pub hide_scrollbars: Option<bool>,
pub ignore_https_errors: Option<bool>,
pub allow_file_access: Option<bool>,
pub cdp: Option<String>,
@@ -131,6 +132,7 @@ impl Config {
user_agent: other.user_agent.or(self.user_agent),
provider: other.provider.or(self.provider),
device: other.device.or(self.device),
hide_scrollbars: other.hide_scrollbars.or(self.hide_scrollbars),
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
allow_file_access: other.allow_file_access.or(self.allow_file_access),
cdp: other.cdp.or(self.cdp),
@@ -187,6 +189,12 @@ fn env_var_is_truthy(name: &str) -> bool {
}
}
fn env_var_bool(name: &str) -> Option<bool> {
env::var(name)
.ok()
.map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""))
}
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
@@ -306,6 +314,7 @@ pub struct Flags {
pub provider: Option<String>,
pub ignore_https_errors: bool,
pub allow_file_access: bool,
pub hide_scrollbars: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub force_launch: bool,
@@ -343,6 +352,7 @@ pub struct Flags {
pub cli_proxy: bool,
pub cli_proxy_bypass: bool,
pub cli_allow_file_access: bool,
pub cli_hide_scrollbars: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_headed: bool,
@@ -443,6 +453,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|| config.ignore_https_errors.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false),
hide_scrollbars: env_var_bool("AGENT_BROWSER_HIDE_SCROLLBARS")
.or(config.hide_scrollbars)
.unwrap_or(true),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
@@ -518,6 +531,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_proxy: false,
cli_proxy_bypass: false,
cli_allow_file_access: false,
cli_hide_scrollbars: false,
cli_annotate: false,
cli_download_path: false,
cli_headed: false,
@@ -677,6 +691,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--hide-scrollbars" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.hide_scrollbars = val;
flags.cli_hide_scrollbars = true;
if consumed {
i += 1;
}
}
"--device" => {
if let Some(d) = args.get(i + 1) {
flags.device = Some(d.clone());
@@ -852,6 +874,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--debug",
"--ignore-https-errors",
"--allow-file-access",
"--hide-scrollbars",
"--auto-connect",
"--launch",
"--new",
@@ -933,6 +956,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
@@ -1176,6 +1200,7 @@ mod tests {
"userAgent": "test-agent",
"provider": "ios",
"device": "iPhone 15",
"hideScrollbars": false,
"ignoreHttpsErrors": true,
"allowFileAccess": true,
"cdp": "9222",
@@ -1201,6 +1226,7 @@ mod tests {
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
assert_eq!(config.provider.as_deref(), Some("ios"));
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
assert_eq!(config.hide_scrollbars, Some(false));
assert_eq!(config.ignore_https_errors, Some(true));
assert_eq!(config.allow_file_access, Some(true));
assert_eq!(config.cdp.as_deref(), Some("9222"));
@@ -1454,6 +1480,33 @@ mod tests {
assert!(flags.cli_allow_file_access);
}
#[test]
fn test_hide_scrollbars_default_true() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let flags = parse_flags(&args("open example.com"));
assert!(flags.hide_scrollbars);
assert!(!flags.cli_hide_scrollbars);
}
#[test]
fn test_hide_scrollbars_false() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let flags = parse_flags(&args("--hide-scrollbars false open"));
assert!(!flags.hide_scrollbars);
assert!(flags.cli_hide_scrollbars);
}
#[test]
fn test_hide_scrollbars_bare_defaults_true() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let flags = parse_flags(&args("--hide-scrollbars open"));
assert!(flags.hide_scrollbars);
assert!(flags.cli_hide_scrollbars);
}
#[test]
fn test_auto_connect_false() {
let flags = parse_flags(&args("--auto-connect false open"));
@@ -1462,7 +1515,9 @@ mod tests {
#[test]
fn test_clean_args_removes_bool_flag_with_value() {
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
let cleaned = clean_args(&args(
"--headed false --debug true --hide-scrollbars false open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
+66 -5
View File
@@ -60,6 +60,23 @@ fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
}));
}
fn should_send_hide_scrollbars_launch_option(
cli_hide_scrollbars: bool,
hide_scrollbars: bool,
) -> bool {
cli_hide_scrollbars || !hide_scrollbars
}
fn apply_hide_scrollbars_launch_option(
launch_cmd: &mut serde_json::Value,
cli_hide_scrollbars: bool,
hide_scrollbars: bool,
) {
if should_send_hide_scrollbars_launch_option(cli_hide_scrollbars, hide_scrollbars) {
launch_cmd["hideScrollbars"] = json!(hide_scrollbars);
}
}
struct ParsedProxy {
server: String,
username: Option<String>,
@@ -741,6 +758,7 @@ fn main() {
proxy_password: proxy_password.as_deref(),
ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access,
hide_scrollbars: flags.hide_scrollbars,
profile: flags.profile.as_deref(),
state: flags.state.as_deref(),
provider: flags.provider.as_deref(),
@@ -814,6 +832,7 @@ fn main() {
},
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_hide_scrollbars.then_some("--hide-scrollbars"),
flags.cli_download_path.then_some("--download-path"),
flags.cli_headed.then_some("--headed"),
]
@@ -822,11 +841,24 @@ fn main() {
.collect();
if !ignored_flags.is_empty() && !flags.json {
eprintln!(
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
color::warning_indicator(),
ignored_flags.join(", ")
);
// Special case: --headed is irrelevant in CDP-attach mode
// (your existing Chrome is always already visible). The
// "agent-browser close + reopen" advice doesn't help because
// the new daemon will attach right back to the same Chrome.
// Don't suggest a useless workaround.
if ignored_flags == ["--headed"] {
eprintln!(
"{} --headed has no effect when attached to your running Chrome (it's already visible). \
Pass --launch to spawn a separate browser if you need to control headedness.",
color::warning_indicator(),
);
} else {
eprintln!(
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
color::warning_indicator(),
ignored_flags.join(", ")
);
}
}
}
@@ -1049,6 +1081,10 @@ fn main() {
|| flags.args.is_some()
|| flags.user_agent.is_some()
|| flags.allow_file_access
|| should_send_hide_scrollbars_launch_option(
flags.cli_hide_scrollbars,
flags.hide_scrollbars,
)
|| flags.color_scheme.is_some()
|| flags.download_path.is_some()
|| flags.engine.is_some()
@@ -1123,6 +1159,12 @@ fn main() {
launch_cmd["allowFileAccess"] = json!(true);
}
apply_hide_scrollbars_launch_option(
&mut launch_cmd,
flags.cli_hide_scrollbars,
flags.hide_scrollbars,
);
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
@@ -1475,4 +1517,23 @@ mod tests {
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
);
}
#[test]
fn test_hide_scrollbars_launch_option_serialization() {
assert!(!should_send_hide_scrollbars_launch_option(false, true));
assert!(should_send_hide_scrollbars_launch_option(false, false));
assert!(should_send_hide_scrollbars_launch_option(true, true));
let mut default_cmd = json!({ "action": "launch" });
apply_hide_scrollbars_launch_option(&mut default_cmd, false, true);
assert!(default_cmd.get("hideScrollbars").is_none());
let mut config_false_cmd = json!({ "action": "launch" });
apply_hide_scrollbars_launch_option(&mut config_false_cmd, false, false);
assert_eq!(config_false_cmd["hideScrollbars"], false);
let mut cli_true_cmd = json!({ "action": "launch" });
apply_hide_scrollbars_launch_option(&mut cli_true_cmd, true, true);
assert_eq!(cli_true_cmd["hideScrollbars"], true);
}
}
+176 -8
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use tokio::sync::{broadcast, oneshot, RwLock};
use crate::connection::get_socket_dir;
use crate::connection::{get_restore_url_path, get_socket_dir};
use super::auth;
use super::browser::{should_track_target, BrowserManager, WaitUntil};
@@ -197,6 +197,7 @@ fn launch_hash(opts: &LaunchOptions) -> u64 {
opts.proxy_password.hash(&mut h);
opts.user_agent.hash(&mut h);
opts.allow_file_access.hash(&mut h);
opts.hide_scrollbars.hash(&mut h);
h.finish()
}
@@ -1510,6 +1511,28 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
.client
.send_command("Page.bringToFront", None, Some(&session_id))
.await;
// Liveness probe: confirm the CDP session can actually round-trip
// before returning success. Without this, a zombie CDP socket (process
// alive, websocket dead) would let `connect_auto` and `tab_new` succeed,
// we'd return Ok, the next user command would silently no-op, and
// `agent-browser open URL` would exit 0 with the browser still on
// about:blank. Failing here lets the caller surface the real error.
if let Err(e) = mgr
.client
.send_command("Runtime.evaluate", Some(serde_json::json!({
"expression": "1",
"returnByValue": true,
})), Some(&session_id))
.await
{
return Err(format!(
"CDP session is unresponsive after attaching ({}). \
The browser may have lost its DevTools connection. \
Try: agent-browser close, then re-run.",
e
));
}
Ok(mgr)
}
@@ -1552,6 +1575,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
try_auto_restore_state(state).await;
try_load_storage_state(state, &storage_state_path).await;
apply_stealth_to_browser(state).await;
try_restore_navigation(state).await;
return Ok(());
}
@@ -1573,6 +1597,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
try_auto_restore_state(state).await;
try_load_storage_state(state, &storage_state_path).await;
apply_stealth_to_browser(state).await;
try_restore_navigation(state).await;
return Ok(());
}
Err(_e) => {
@@ -1583,8 +1608,9 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
To let agent-browser work with your existing Chrome (recommended):\n\
{}\n\n\
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
Tip: On Chrome 144+, you can enable CDP without restarting:\n\
Open chrome://inspect/#remote-debugging and toggle it on.",
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
it does NOT expose the standard CDP HTTP API on /json/version. \
A full restart with --remote-debugging-port=<port> is required.",
chrome_relaunch_hint(),
));
}
@@ -1660,6 +1686,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
try_load_storage_state(state, &storage_state_path).await;
// Apply stealth anti-detection patches after browser is ready
apply_stealth_to_browser(state).await;
try_restore_navigation(state).await;
Ok(())
}
@@ -1770,6 +1797,47 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
}
}
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
/// by a version-mismatch restart), navigate the freshly-connected browser to
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
/// the page the user was on. Read-and-delete: the file is removed regardless
/// of whether navigation succeeds, so a stale sidecar can't haunt later
/// auto-launches.
async fn try_restore_navigation(state: &mut DaemonState) {
let path = get_restore_url_path(&state.session_id);
let url = match fs::read_to_string(&path) {
Ok(s) => s.trim().to_string(),
Err(_) => return,
};
let _ = fs::remove_file(&path);
if url.is_empty() {
return;
}
let Some(mgr) = state.browser.as_mut() else {
return;
};
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
match mgr.navigate(&url, super::browser::WaitUntil::Load).await {
Ok(_) => {
eprintln!(
"{} Restored previous URL: {}",
crate::color::warning_indicator(),
url
);
}
Err(e) => {
eprintln!(
"{} Could not restore previous URL ({}): {}",
crate::color::warning_indicator(),
url,
e
);
}
}
}
fn launch_options_from_env() -> LaunchOptions {
let headed = env::var("AGENT_BROWSER_HEADED")
.map(|v| v == "1" || v == "true")
@@ -1809,11 +1877,24 @@ fn launch_options_from_env() -> LaunchOptions {
.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
hide_scrollbars: hide_scrollbars_from_env(),
viewport_size: None,
use_real_keychain: false,
}
}
fn hide_scrollbars_from_env() -> bool {
env::var("AGENT_BROWSER_HIDE_SCROLLBARS")
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | ""))
.unwrap_or(true)
}
fn hide_scrollbars_from_launch_cmd(cmd: &Value) -> bool {
cmd.get("hideScrollbars")
.and_then(|v| v.as_bool())
.unwrap_or_else(hide_scrollbars_from_env)
}
async fn try_auto_restore_state(state: &mut DaemonState) {
let session_name = match state.session_name.as_deref() {
Some(n) if !n.is_empty() => n.to_string(),
@@ -1977,6 +2058,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.get("downloadPath")
.and_then(|v| v.as_str())
.map(String::from),
hide_scrollbars: hide_scrollbars_from_launch_cmd(cmd),
viewport_size: None,
use_real_keychain: false,
};
@@ -2069,8 +2151,9 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
To let agent-browser work with your existing Chrome (recommended):\n\
{}\n\n\
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
Tip: On Chrome 144+, you can enable CDP without restarting:\n\
Open chrome://inspect/#remote-debugging and toggle it on.",
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
it does NOT expose the standard CDP HTTP API on /json/version. \
A full restart with --remote-debugging-port=<port> is required.",
chrome_relaunch_hint(),
));
}
@@ -3061,6 +3144,15 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("visible");
// @-ref support: if the selector is `@e12` style, poll the ref map +
// accessibility tree instead of `document.querySelector`. This makes
// `wait @e8 --gone` a usable "assert modal still mounted" primitive
// for SPA flows where the only stable identity is the AX role+name
// captured at snapshot time.
if selector.starts_with('@') {
wait_for_ref(state, selector, state_str, timeout_ms).await?;
return Ok(json!({ "waited": "ref", "ref": selector, "state": state_str }));
}
wait_for_selector(&mgr.client, &session_id, selector, state_str, timeout_ms).await?;
return Ok(json!({ "waited": "selector", "selector": selector }));
}
@@ -3272,6 +3364,49 @@ async fn handle_reload(state: &mut DaemonState) -> Result<Value, String> {
// Wait helpers
// ---------------------------------------------------------------------------
/// Poll-based wait for a ref-identified element. Resolves the @-ref by
/// re-running the ref-identity verification each iteration. The supported
/// states mirror selector-based waits:
///
/// - "visible" / "attached" — succeed when the ref resolves to a node
/// whose AX role + name still match the snapshot entry
/// - "detached" / "hidden" — succeed when the ref no longer matches
/// (node removed OR re-textified to something else)
///
/// Times out with a "ref X did not become {state}" error.
async fn wait_for_ref(
state: &mut DaemonState,
ref_selector: &str,
desired_state: &str,
timeout_ms: u64,
) -> Result<(), String> {
let want_present = !matches!(desired_state, "detached" | "hidden");
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let resolved = super::element::resolve_element_object_id(
&mgr.client,
&session_id,
&state.ref_map,
ref_selector,
&state.iframe_sessions,
)
.await;
let present = resolved.is_ok();
if present == want_present {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"Timeout: ref {} did not become {} within {}ms",
ref_selector, desired_state, timeout_ms
));
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
}
async fn wait_for_selector(
client: &super::cdp::client::CdpClient,
session_id: &str,
@@ -8526,17 +8661,21 @@ mod tests {
#[test]
fn test_launch_options_from_env_defaults() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HEADED");
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let opts = launch_options_from_env();
assert!(opts.headless);
assert!(opts.args.is_empty());
assert!(!opts.allow_file_access);
assert!(opts.hide_scrollbars);
}
#[test]
fn test_launch_options_from_env_headed_flag() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
_guard.set("AGENT_BROWSER_HEADED", "1");
let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.set("AGENT_BROWSER_HEADED", "1");
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let opts = launch_options_from_env();
assert!(
!opts.headless,
@@ -8544,6 +8683,35 @@ mod tests {
);
}
#[test]
fn test_launch_options_from_env_hide_scrollbars_false() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
let opts = launch_options_from_env();
assert!(!opts.hide_scrollbars);
}
#[test]
fn test_launch_cmd_hide_scrollbars_missing_uses_env_default() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
assert!(!hide_scrollbars_from_launch_cmd(&json!({
"action": "launch"
})));
}
#[test]
fn test_launch_cmd_hide_scrollbars_explicit_overrides_env_default() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
assert!(hide_scrollbars_from_launch_cmd(&json!({
"action": "launch",
"hideScrollbars": true
})));
}
#[test]
fn test_har_entry_to_json_enriches_request_and_response() {
// wall_time: 2026-03-15T12:00:00Z = 1_773_576_000
+80 -9
View File
@@ -311,6 +311,42 @@ const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10);
/// Outcome of a single `Browser.getVersion` liveness probe.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LivenessProbe {
/// Chrome answered — the connection is definitely alive.
Responded,
/// The CDP transport errored (WebSocket closed/reset) — the socket is gone.
TransportError,
/// The probe timed out with no response.
TimedOut,
}
/// Decide whether a CDP connection should be considered alive from one probe.
///
/// The subtle case is [`LivenessProbe::TimedOut`]. For a browser we launched
/// ourselves (`is_external_attach == false`) a hung CDP socket is a real
/// problem and the daemon should reconnect. But for an *externally attached*
/// browser — the stealth fork's default, where we attach to the user's real
/// Chrome — a slow/no response is almost always Chrome being briefly busy or,
/// critically, showing the Chrome 136+ "Allow remote debugging?" consent modal,
/// which blocks CDP responses until the user clicks Allow.
///
/// Treating that timeout as "dead" tears down the already-consented connection
/// and forces a reconnect, which re-pops the consent prompt; repeated on every
/// command it produces an endless prompt loop and a connection storm that can
/// freeze Chrome. So for external attaches we keep the connection alive on
/// timeout. A genuinely dead external socket instead surfaces as
/// [`LivenessProbe::TransportError`] (and Chrome being closed by the user is a
/// transport error, not a timeout), so zombie-socket detection is preserved.
fn connection_alive_from_probe(probe: LivenessProbe, is_external_attach: bool) -> bool {
match probe {
LivenessProbe::Responded => true,
LivenessProbe::TransportError => false,
LivenessProbe::TimedOut => is_external_attach,
}
}
impl BrowserManager {
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
let engine = engine.unwrap_or("chrome");
@@ -829,21 +865,27 @@ impl BrowserManager {
self.default_timeout_ms
}
/// Checks if the CDP connection is alive by sending a simple command.
/// Returns false if the command times out or fails.
/// Checks if the CDP connection is alive by sending a `Browser.getVersion`
/// probe. See [`connection_alive_from_probe`] for how the outcome maps to a
/// liveness verdict — in particular why a timeout does NOT tear down an
/// externally-attached browser.
pub async fn is_connection_alive(&self) -> bool {
let timeout = tokio::time::Duration::from_secs(3);
let result = tokio::time::timeout(
let probe = match tokio::time::timeout(
timeout,
self.client
.send_command_no_params("Browser.getVersion", None),
)
.await;
match result {
Ok(Ok(_)) => true,
Ok(Err(_)) | Err(_) => false,
}
.await
{
Ok(Ok(_)) => LivenessProbe::Responded,
Ok(Err(_)) => LivenessProbe::TransportError,
Err(_) => LivenessProbe::TimedOut,
};
// No child process => we attached to an external browser (the user's
// real Chrome — the stealth fork's default).
let is_external_attach = self.browser_process.is_none();
connection_alive_from_probe(probe, is_external_attach)
}
/// Non-blocking check whether the locally-launched browser process has exited
@@ -1728,6 +1770,35 @@ mod tests {
assert_eq!(format_tab_id(42), "t42");
}
#[test]
fn liveness_responded_is_alive_for_both_kinds() {
assert!(connection_alive_from_probe(LivenessProbe::Responded, true));
assert!(connection_alive_from_probe(LivenessProbe::Responded, false));
}
#[test]
fn liveness_transport_error_is_dead_for_both_kinds() {
// A closed/reset WebSocket is a genuine death — reconnect in both cases.
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, true));
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, false));
}
#[test]
fn liveness_timeout_keeps_external_attach_alive() {
// Regression guard for the remote-debugging consent storm: a timed-out
// probe must NOT tear down an externally-attached browser, otherwise the
// daemon reconnects and re-pops Chrome's "Allow remote debugging?" modal
// on every command (endless prompts + browser freeze).
assert!(connection_alive_from_probe(LivenessProbe::TimedOut, true));
}
#[test]
fn liveness_timeout_marks_launched_browser_dead() {
// A browser we launched that stops responding is a real problem worth a
// reconnect (and has no consent modal to worry about).
assert!(!connection_alive_from_probe(LivenessProbe::TimedOut, false));
}
#[test]
fn test_parse_tab_ref_id() {
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
+107 -96
View File
@@ -103,6 +103,9 @@ pub struct LaunchOptions {
pub ignore_https_errors: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
/// Hide native scrollbars in headless Chromium screenshots by launching
/// Chrome with `--hide-scrollbars`.
pub hide_scrollbars: bool,
/// Initial viewport dimensions used for `--window-size` so the content
/// area matches the desired viewport from the start.
pub viewport_size: Option<(u32, u32)>,
@@ -130,6 +133,7 @@ impl Default for LaunchOptions {
ignore_https_errors: false,
color_scheme: None,
download_path: None,
hide_scrollbars: true,
viewport_size: None,
use_real_keychain: false,
}
@@ -178,6 +182,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
// injected in headless mode). Skip --headless when extensions are loaded.
if options.headless && !has_extensions {
args.push("--headless=new".to_string());
// Linux paints native scrollbars into viewport screenshots unless
// Chrome is launched with this flag. `--hide-scrollbars` is
// presence-based, so agent-browser exposes --hide-scrollbars false
// as the public opt-out instead of forwarding a fake inverse switch.
if options.hide_scrollbars {
args.push("--hide-scrollbars".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)
@@ -679,52 +690,52 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
///
/// Tries the exact WebSocket path from DevToolsActivePort first (single
/// prompt on M144+), then falls back to legacy HTTP discovery for older
/// Chrome versions. This order avoids triggering duplicate remote-debugging
/// permission prompts (#1210, #1206).
/// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only
/// by a consent-free TCP liveness check. Falls back to HTTP discovery on the
/// same port for older Chrome layouts.
///
/// Crucially, this does NOT open a throwaway verification WebSocket. On
/// Chrome 136+ the "Allow remote debugging?" consent is granted *per
/// connection*: a probe WebSocket we then close would consume the user's one
/// Allow click, leaving the real connection (opened afterwards) unconsented —
/// which manifests as an endless prompt loop or a hung command. By skipping the
/// probe, the real connection is the single WebSocket the user consents to.
/// (Background: #1210, #1206 duplicate-prompt reports.)
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
if verify_ws_endpoint(&ws_url).await {
return Ok(ws_url);
// Consent-free liveness: a bare TCP connect does not trigger the
// remote-debugging consent flow (that fires on the CDP/WebSocket upgrade),
// so we can tell "Chrome is listening" from "stale DevToolsActivePort"
// without burning a prompt.
if tcp_port_alive(port).await {
return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path));
}
// Pre-M144 fallback: HTTP endpoints (/json/version, /json/list, etc.)
// Port isn't accepting connections (stale file / different layout). Fall
// back to HTTP discovery for older Chrome before giving up.
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
return Ok(ws_url);
}
Err(format!(
"Cannot connect to Chrome on port {}: both direct WebSocket and HTTP discovery failed",
"Cannot connect to Chrome on port {}: port not reachable and HTTP discovery failed",
port
))
}
/// Verify that a WebSocket endpoint is a live CDP server by sending
/// `Browser.getVersion` and checking for a valid response.
async fn verify_ws_endpoint(ws_url: &str) -> bool {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
let timeout = Duration::from_secs(2);
let result = tokio::time::timeout(timeout, async {
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?;
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
ws.send(Message::Text(cmd.into())).await.ok()?;
while let Some(Ok(msg)) = ws.next().await {
if let Message::Text(text) = msg {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if v.get("id").and_then(|id| id.as_u64()) == Some(1) {
let _ = ws.close(None).await;
return Some(());
}
}
}
}
None
})
.await;
matches!(result, Ok(Some(())))
/// Consent-free check that something is accepting TCP connections on
/// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not
/// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to
/// use for liveness before handing the URL to the single real connection.
async fn tcp_port_alive(port: u16) -> bool {
let timeout = Duration::from_secs(1);
matches!(
tokio::time::timeout(
timeout,
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
/// Returns the default Chrome user-data directory paths for the current platform.
@@ -1360,6 +1371,7 @@ mod tests {
};
let result = build_chrome_args(&opts).unwrap();
assert!(result.args.iter().any(|a| a == "--headless=new"));
assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
assert!(result
.args
.iter()
@@ -1380,6 +1392,7 @@ mod tests {
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result.args.iter().any(|a| a.contains("--headless")));
assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
assert!(!result
.args
.iter()
@@ -1434,6 +1447,23 @@ mod tests {
}
}
#[test]
fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
let opts = LaunchOptions {
headless: true,
hide_scrollbars: false,
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(
!result.args.iter().any(|a| a == "--hide-scrollbars"),
"--hide-scrollbars false should suppress agent-browser's default hide switch"
);
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 {
@@ -1474,6 +1504,10 @@ mod tests {
!result.args.iter().any(|a| a.contains("--headless")),
"headless flag should be omitted when extensions are present"
);
assert!(
!result.args.iter().any(|a| a == "--hide-scrollbars"),
"scrollbars should remain visible when extensions force headed mode"
);
assert!(
!result.args.iter().any(|a| a.contains("--window-size")),
"window-size should be omitted when extensions force headed mode"
@@ -1870,83 +1904,60 @@ mod tests {
// auto_connect_cdp discovery-order tests (#1210, #1206)
// -------------------------------------------------------------------
/// When DevToolsActivePort provides a ws_path and the port is reachable,
/// `resolve_cdp_from_active_port` should return the exact ws_path URL
/// WITHOUT calling HTTP discovery first.
/// When the port is live, `resolve_cdp_from_active_port` returns the exact
/// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT
/// probe with a verification WebSocket (which would burn Chrome 136+'s
/// per-connection remote-debugging consent on a throwaway socket).
#[tokio::test]
async fn test_resolve_cdp_from_active_port_prefers_ws_path() {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMsg;
async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() {
// A bound listener makes the port TCP-reachable. We do NOT accept/serve
// any WebSocket — resolve must succeed from the bare TCP check alone.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let ws_path = "/devtools/browser/test-uuid-1234".to_string();
let ws_path = "/devtools/browser/test-uuid-1234";
let server = tokio::spawn(async move {
// accept: verify_ws_endpoint() WebSocket handshake
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
if let Some(Ok(WsMsg::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/147"}}}}"#,
id
);
ws.send(WsMsg::Text(reply)).await.unwrap();
}
let _ = ws.close(None).await;
});
let result = resolve_cdp_from_active_port(port, &ws_path).await;
assert!(result.is_ok(), "should succeed: {:?}", result);
let url = result.unwrap();
assert!(
url.contains("test-uuid-1234"),
"should use exact ws_path from DevToolsActivePort, got: {}",
url
let result = resolve_cdp_from_active_port(port, ws_path).await;
assert!(result.is_ok(), "should succeed when port is live: {:?}", result);
assert_eq!(
result.unwrap(),
format!("ws://127.0.0.1:{}{}", port, ws_path),
"should return the exact DevToolsActivePort URL untouched"
);
assert_eq!(url, format!("ws://127.0.0.1:{}{}", port, ws_path));
server.await.unwrap();
drop(listener);
}
/// When the exact ws_path connection fails, `resolve_cdp_from_active_port`
/// should fall back to HTTP discovery.
/// Regression guard for the consent storm: resolving the URL must only do a
/// bare TCP connect, never a WebSocket/CDP handshake. On Chrome 136+ a
/// handshake on a throwaway socket consumes the user's one "Allow remote
/// debugging?" click, leaving the real connection unconsented (endless
/// prompts / hang).
#[tokio::test]
async fn test_resolve_cdp_from_active_port_falls_back_to_http_discovery() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn test_resolve_cdp_from_active_port_does_not_open_websocket() {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
// 1st accept: verify_ws_endpoint() ws_path probe — reject (just close)
let (s1, _) = listener.accept().await.unwrap();
drop(s1);
// 2nd accept: HTTP /json/version from discover_cdp_url()
let (mut s2, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 2048];
let _ = s2.read(&mut buf).await;
let body = format!(
r#"{{"webSocketDebuggerUrl":"ws://127.0.0.1:{}/devtools/browser/fallback-uuid"}}"#,
port
);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
body.len(),
body
);
s2.write_all(resp.as_bytes()).await.unwrap();
let (mut stream, _) = listener.accept().await.unwrap();
// The liveness check connects then drops without writing anything.
// Assert we receive no WebSocket upgrade bytes (EOF / no data).
let mut buf = [0u8; 128];
let read = tokio::time::timeout(
Duration::from_millis(500),
stream.read(&mut buf),
)
.await;
match read {
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
}
});
let result = resolve_cdp_from_active_port(port, "/devtools/browser/nonexistent-uuid").await;
assert!(result.is_ok(), "should fall back to HTTP: {:?}", result);
let url = result.unwrap();
assert!(
url.contains("fallback-uuid"),
"should use HTTP discovery fallback, got: {}",
url
let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await;
assert_eq!(
result.unwrap(),
format!("ws://127.0.0.1:{}/devtools/browser/abc", port)
);
server.await.unwrap();
}
+147
View File
@@ -94,6 +94,61 @@ async fn create_storage_state_with_cookie(path: &str, cookie_name: &str, cookie_
assert_success(&resp);
}
async fn send_raw_http_request(port: u64, request: &str) -> String {
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
.await
.expect("HTTP client should connect to stream server");
stream
.write_all(request.as_bytes())
.await
.expect("HTTP request should be written");
stream
.shutdown()
.await
.expect("HTTP client write side should shut down");
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.await
.expect("HTTP response should be read");
String::from_utf8(response).expect("HTTP response should be utf-8")
}
#[cfg(unix)]
async fn spawn_fake_daemon_socket(
socket_dir: &std::path::Path,
session_name: &str,
) -> tokio::sync::oneshot::Receiver<String> {
use tokio::io::AsyncBufReadExt;
let socket_path = socket_dir.join(format!("{session_name}.sock"));
let _ = std::fs::remove_file(&socket_path);
let listener =
tokio::net::UnixListener::bind(&socket_path).expect("fake daemon socket should bind");
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let mut reader = tokio::io::BufReader::new(stream);
let mut command = String::new();
if reader.read_line(&mut command).await.is_err() {
return;
}
let mut stream = reader.into_inner();
let _ = stream
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
.await;
let _ = stream.write_all(b"\n").await;
let _ = tx.send(command);
});
rx
}
// ---------------------------------------------------------------------------
// Core: launch, navigate, evaluate, url, title, close
// ---------------------------------------------------------------------------
@@ -363,6 +418,98 @@ async fn e2e_runtime_stream_enable_before_launch_attaches_and_disables() {
let _ = std::fs::remove_dir_all(&socket_dir);
}
#[cfg(unix)]
#[tokio::test]
#[ignore]
async fn e2e_stream_command_requires_same_origin_before_daemon_relay() {
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("t");
std::fs::create_dir_all(&temp_parent).expect("socket temp parent should be created");
let socket_dir = tempfile::Builder::new()
.prefix("ab-e2e-")
.tempdir_in(temp_parent)
.expect("socket dir should be created");
guard.set(
"AGENT_BROWSER_SOCKET_DIR",
socket_dir
.path()
.to_str()
.expect("socket dir should be utf-8"),
);
guard.set("AGENT_BROWSER_SESSION", "x");
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "stream_enable", "port": 0 }),
&mut state,
)
.await;
assert_success(&resp);
let port = get_data(&resp)["port"]
.as_u64()
.expect("stream enable should report the bound port");
let mut daemon_command = spawn_fake_daemon_socket(socket_dir.path(), "x").await;
let body = r#"{"action":"tabs"}"#;
let cross_origin_request = format!(
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_raw_http_request(port, &cross_origin_request).await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected cross-origin response: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"forbidden command response exposed wildcard CORS: {response}"
);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(100), &mut daemon_command)
.await
.is_err(),
"cross-origin command request reached daemon relay"
);
let same_origin_request = format!(
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_raw_http_request(port, &same_origin_request).await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"unexpected same-origin response: {response}"
);
assert!(
response.contains(&format!(
"Access-Control-Allow-Origin: http://localhost:{port}"
)),
"same-origin command response did not reflect origin: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"same-origin command response exposed wildcard CORS: {response}"
);
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
.await
.expect("same-origin request should reach fake daemon")
.expect("fake daemon should return relayed command");
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
let resp = execute_command(
&json!({ "id": "2", "action": "stream_disable" }),
&mut state,
)
.await;
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// Snapshot with refs and ref-based click
// ---------------------------------------------------------------------------
+294
View File
@@ -163,6 +163,30 @@ pub async fn resolve_element_center(
// Try cached backend_node_id first (fast path)
if let Some(backend_node_id) = entry.backend_node_id {
// Identity check: React often re-uses the same DOM node when
// re-rendering — backendNodeId stays the same but accessibleName
// / role changes. Without this verification, `click @e20` (saved
// when the button said "Add post") happily clicks the *same*
// node that now says "Post all", silently submitting the thread.
//
// Set AGENT_BROWSER_VERIFY_REF=0 to skip (saves one CDP
// roundtrip per ref-based interaction; only safe if you know
// the page is static between snapshot and click).
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
if let Err(e) = verify_ref_identity(
client,
effective_session_id,
backend_node_id,
&ref_id,
&entry.role,
&entry.name,
)
.await
{
return Err(e);
}
}
let result: Result<DomGetBoxModelResult, String> = client
.send_command_typed(
"DOM.getBoxModel",
@@ -177,6 +201,31 @@ pub async fn resolve_element_center(
if let Ok(r) = result {
let (x, y) = box_model_center(&r.model);
// Occlusion check: a transient overlay (X.com's "click
// outside to close" mask, modal backdrop, sticky banner,
// etc.) can land on top of our target between snapshot
// and click. Coordinates are correct, but
// `document.elementFromPoint(x, y)` returns the overlay
// — and the click goes to the overlay's handler, not
// ours. Catch it here so the user gets "occluded by
// DIV[testid=mask]" instead of "modal silently closed +
// thread submitted by accident".
//
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
if let Err(e) = verify_click_target(
client,
effective_session_id,
backend_node_id,
&ref_id,
x,
y,
)
.await
{
return Err(e);
}
}
return Ok((x, y, effective_session_id.to_string()));
}
// backend_node_id is stale; re-query the accessibility tree below
@@ -230,6 +279,24 @@ pub async fn resolve_element_object_id(
// Try cached backend_node_id first (fast path)
if let Some(backend_node_id) = entry.backend_node_id {
// Same identity guard as resolve_element_center — see that
// function for why React DOM-node-reuse breaks ref-based
// interactions if we skip this.
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
if let Err(e) = verify_ref_identity(
client,
effective_session_id,
backend_node_id,
&ref_id,
&entry.role,
&entry.name,
)
.await
{
return Err(e);
}
}
let result: Result<DomResolveNodeResult, String> = client
.send_command_typed(
"DOM.resolveNode",
@@ -333,6 +400,233 @@ fn resolve_frame_session<'a>(
.unwrap_or(session_id)
}
/// Verify that the cached backendNodeId still has the same accessible role
/// and name it had when the snapshot ran. Catches the case where React (or
/// any reconciler) reused the DOM node for a different component instance
/// — same physical node, different semantics.
///
/// On mismatch, returns an actionable error naming both the snapshot label
/// and the current label so the agent can re-snapshot intelligently.
/// On any CDP failure (e.g. node deleted), returns Ok(()) so the caller's
/// existing fallback (`find_node_id_by_role_name`) takes over.
async fn verify_ref_identity(
client: &CdpClient,
session_id: &str,
backend_node_id: i64,
ref_id: &str,
expected_role: &str,
expected_name: &str,
) -> Result<(), String> {
let params = serde_json::json!({
"backendNodeId": backend_node_id,
"fetchRelatives": false,
});
// Tight 1s timeout: this is a defensive guard, not a critical path.
// The default 30s CDP timeout was the dominant factor in the
// "click hangs 5+ minutes" report — three CDP calls (verify +
// resolveNode + paint-settle) at 30s each, multiplied by parallel
// click invocations queueing on the daemon, totalled multi-minute
// user-visible hangs. Cap our own helper so a stuck AX query
// doesn't make `click` worse than the no-guard version was.
let resp: Result<GetFullAXTreeResult, String> = match tokio::time::timeout(
std::time::Duration::from_secs(1),
client.send_command_typed("Accessibility.getPartialAXTree", &params, Some(session_id)),
)
.await
{
Ok(r) => r,
// Timeout: skip identity verification rather than block the click.
Err(_) => return Ok(()),
};
let Ok(tree) = resp else {
// Node likely gone; let the box-model call fail and trigger fallback.
return Ok(());
};
// Find the AXNode for our backendNodeId. fetchRelatives=false still
// returns ancestors; the target node has the matching backendNodeId.
let Some(node) = tree
.nodes
.iter()
.find(|n| n.backend_d_o_m_node_id == Some(backend_node_id))
else {
return Ok(());
};
let actual_role = extract_ax_string(&node.role);
let actual_name = extract_ax_string(&node.name);
if actual_role == expected_role && actual_name == expected_name {
return Ok(());
}
Err(format!(
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
The DOM mutated between snapshot and interaction (typical with React/Vue \
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
ref_id, expected_role, expected_name, actual_role, actual_name,
))
}
/// At the moment we'd dispatch the click, ask the page itself which element
/// occupies (x, y). If it's not our target (and not a descendant or
/// ancestor), an overlay has appeared between snapshot and click — we'd
/// silently click the overlay otherwise. Returns Err with details about
/// the occluding element so the caller can wait + re-snapshot.
///
/// Implemented as a single Runtime.callFunctionOn: resolve the cached
/// backendNodeId to a remote object, then run a function on it that
/// compares with elementFromPoint. The function returns null when the
/// click is safe and a JSON string with diagnostic info when it isn't.
async fn verify_click_target(
client: &CdpClient,
session_id: &str,
backend_node_id: i64,
ref_id: &str,
x: f64,
y: f64,
) -> Result<(), String> {
use serde::Deserialize;
// Resolve once. backendNodeId is stable across renders; only the
// element under (x, y) is what changes when an overlay flickers.
let resolve_params = DomResolveNodeParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_group: Some("agent-browser-occlusion".to_string()),
};
let resolve_fut = client.send_command_typed::<_, serde_json::Value>(
"DOM.resolveNode",
&resolve_params,
Some(session_id),
);
let Ok(resolve_resp) =
tokio::time::timeout(std::time::Duration::from_millis(500), resolve_fut).await
else {
return Ok(());
};
let Ok(resolved) = resolve_resp else { return Ok(()) };
let Some(object_id) = resolved
.get("object")
.and_then(|o| o.get("objectId"))
.and_then(|v| v.as_str())
else {
return Ok(());
};
// Auto-retry on transient occlusion. Many real-world overlays
// (modal backdrops, focus rings, click-outside masks) blink in for
// a frame or two during state transitions and clear on their own.
// Without retries the user gets an "occluded" error and has to
// wrap every click in their own retry loop. With retries the
// common case is invisible — only persistent overlays surface.
//
// AGENT_BROWSER_OCCLUSION_RETRIES (default 3, 0 disables)
// AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS (default 200)
let max_retries: u32 = std::env::var("AGENT_BROWSER_OCCLUSION_RETRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3);
let retry_delay_ms: u64 = std::env::var("AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(200);
#[derive(Deserialize)]
struct Occluder {
tag: Option<String>,
testid: Option<String>,
role: Option<String>,
#[serde(rename = "ariaLabel")]
aria_label: Option<String>,
text: Option<String>,
reason: Option<String>,
}
// function(x, y) { ... } where `this` is the target element.
// Return null → click is safe.
// Return JSON → describes the occluding element.
let function_decl = "function(x, y) { \
const at = document.elementFromPoint(x, y); \
if (!at) return JSON.stringify({reason:'no-element-at-point'}); \
if (at === this || this.contains(at) || at.contains(this)) return null; \
return JSON.stringify({ \
tag: at.tagName, \
testid: (at.dataset && at.dataset.testid) || null, \
role: at.getAttribute('role'), \
ariaLabel: at.getAttribute('aria-label'), \
text: ((at.textContent||'').trim().slice(0, 60)) \
}); \
}";
let mut last_occ: Option<Occluder> = None;
for attempt in 0..=max_retries {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await;
}
let call_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": function_decl,
"arguments": [{"value": x}, {"value": y}],
"returnByValue": true,
});
let call_fut = client.send_command_typed::<_, serde_json::Value>(
"Runtime.callFunctionOn",
&call_params,
Some(session_id),
);
let Ok(call_resp) =
tokio::time::timeout(std::time::Duration::from_millis(500), call_fut).await
else {
return Ok(()); // probe itself stalled — fall through to click
};
let Ok(call_result) = call_resp else {
return Ok(());
};
let value = call_result.get("result").and_then(|r| r.get("value"));
let json_str = match value {
Some(serde_json::Value::String(s)) => s.clone(),
// null / undefined → element at point IS our target. Safe.
_ => return Ok(()),
};
let occ: Occluder = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(_) => return Ok(()),
};
last_occ = Some(occ);
}
// All retries exhausted — overlay is sticky. Build the descriptive error.
let occ = last_occ.expect("loop ran at least once");
if let Some(reason) = occ.reason {
return Err(format!(
"Ref {} cannot be clicked at its computed position: {}. \
The element may have moved off-screen re-run snapshot.",
ref_id, reason
));
}
let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string());
if let Some(t) = occ.testid {
desc.push_str(&format!("[testid={}]", t));
}
if let Some(r) = occ.role {
desc.push_str(&format!("[role={}]", r));
}
if let Some(a) = occ.aria_label {
desc.push_str(&format!("[aria-label=\"{}\"]", a));
}
if let Some(t) = occ.text {
if !t.is_empty() {
desc.push_str(&format!(" text=\"{}\"", t));
}
}
let waited_ms = (max_retries as u64) * retry_delay_ms;
Err(format!(
"Ref {} is occluded by {} at the click point (still occluded after \
{} retries / {}ms). A persistent overlay is in the way \
re-run snapshot, dismiss the overlay, or set \
AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass.",
ref_id, desc, max_retries, waited_ms,
))
}
/// 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,
+41
View File
@@ -884,6 +884,46 @@ pub async fn tap_touch(
Ok(())
}
/// After a click is dispatched, give the page two animation frames + a
/// microtask boundary to let React/Vue/Svelte commit any state update
/// scheduled by the click handler. Without this wait, follow-up commands
/// (e.g. `inserttext` against the textbox the click was supposed to mount)
/// race the renderer and can land on stale or wrong elements.
///
/// The wait is bounded to ~33ms in the common case (two RAFs at 60fps) and
/// returns immediately on any error — never an exception path.
///
/// Set `AGENT_BROWSER_CLICK_WAIT_STABLE=0` to disable for perf-sensitive
/// scripts that don't drive SPA UIs.
async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
if std::env::var("AGENT_BROWSER_CLICK_WAIT_STABLE").as_deref() == Ok("0") {
return;
}
let script = "new Promise(resolve => \
requestAnimationFrame(() => \
requestAnimationFrame(() => \
queueMicrotask(() => resolve(true)))))";
// Tight 500ms timeout. RAF normally fires at 16ms, two RAFs total ~33ms.
// If the tab is hidden / throttled / page is doing something pathological
// and RAF doesn't fire in 500ms, we'd rather return now than stall the
// user's click. Without this cap, a stuck RAF inherited the default 30s
// CDP timeout and was the main contributor to the "click hangs 5+ min"
// user report.
let _ = tokio::time::timeout(
std::time::Duration::from_millis(500),
client.send_command_typed::<_, Value>(
"Runtime.evaluate",
&EvaluateParams {
expression: script.to_string(),
return_by_value: Some(true),
await_promise: Some(true),
},
Some(session_id),
),
)
.await;
}
async fn dispatch_click(
client: &CdpClient,
session_id: &str,
@@ -955,6 +995,7 @@ async fn dispatch_click(
)
.await?;
wait_for_paint_settled(client, session_id).await;
Ok(())
}
+406 -6
View File
@@ -33,14 +33,141 @@ pub(super) fn cors_headers_for_origin(origin: Option<&str>) -> String {
)
}
fn request_headers(request: &str) -> &str {
request
.find("\r\n\r\n")
.or_else(|| request.find("\n\n"))
.map(|header_end| &request[..header_end])
.unwrap_or(request)
}
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request_headers(request).lines().find_map(|line| {
let (header_name, value) = line.split_once(':')?;
if header_name.trim().eq_ignore_ascii_case(name) {
Some(value.trim())
} else {
None
}
})
}
fn parse_origin(peeked: &[u8]) -> Option<String> {
let header_str = std::str::from_utf8(peeked).ok()?;
for line in header_str.lines() {
if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") {
return Some(line[8..].trim().to_string());
request_header_value(header_str, "origin").map(ToString::to_string)
}
fn normalize_origin_authority(origin: &str) -> Option<String> {
let url = url::Url::parse(origin).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
let host = if host.contains(':') {
format!("[{host}]")
} else {
host
};
let default_port = (url.scheme() == "http" && url.port() == Some(80))
|| (url.scheme() == "https" && url.port() == Some(443));
Some(match url.port() {
Some(port) if !default_port => format!("{host}:{port}"),
_ => host,
})
}
fn normalize_host_authority(host: &str) -> String {
let host = host.trim().to_ascii_lowercase();
if let Some(bracket_end) = host.rfind(']') {
if bracket_end == host.len() - 1 {
return host;
}
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
let port = &host[bracket_end + 2..];
if port == "80" || port == "443" {
return host[..=bracket_end].to_string();
}
}
return host;
}
if let Some((name, port)) = host.rsplit_once(':') {
if !name.contains(':') && (port == "80" || port == "443") {
return name.to_string();
}
}
None
host
}
fn authority_host(authority: &str) -> &str {
if let Some(stripped) = authority.strip_prefix('[') {
if let Some(bracket_end) = stripped.find(']') {
return &authority[..=bracket_end + 1];
}
}
if let Some((host, _port)) = authority.rsplit_once(':') {
if !host.contains(':') {
return host;
}
}
authority
}
fn is_loopback_authority(authority: &str) -> bool {
matches!(
authority_host(authority),
"localhost" | "127.0.0.1" | "::1" | "[::1]"
)
}
fn header_authority_matches_host(request: &str, header_name: &str) -> bool {
let Some(authority) =
request_header_value(request, header_name).and_then(normalize_origin_authority)
else {
return false;
};
let Some(host) = request_header_value(request, "host").map(normalize_host_authority) else {
return false;
};
authority == host && is_loopback_authority(&authority) && is_loopback_authority(&host)
}
/// Protects the command relay by requiring same-origin browser metadata.
fn is_same_origin_command_request(request: &str) -> bool {
if request_header_value(request, "origin").is_some() {
header_authority_matches_host(request, "origin")
} else {
header_authority_matches_host(request, "referer")
}
}
fn command_cors_headers(request: &str) -> String {
match request_header_value(request, "origin") {
Some(origin) if is_same_origin_command_request(request) => format!(
"Access-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nVary: Origin\r\n"
),
_ => String::new(),
}
}
async fn write_json_error_response_no_cors(
stream: &mut tokio::net::TcpStream,
status: &str,
error: &str,
) {
let body = format!(
r#"{{"success":false,"error":{}}}"#,
serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error))
);
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(body.as_bytes()).await;
}
pub(super) async fn handle_http_request(
@@ -61,6 +188,25 @@ pub(super) async fn handle_http_request(
let origin = parse_origin(peeked);
if method == "OPTIONS" {
if path == "/api/command" {
if !is_same_origin_command_request(&request) {
write_json_error_response_no_cors(
&mut stream,
"403 Forbidden",
"Origin or Referer does not match Host header.",
)
.await;
return;
}
let cors_headers = command_cors_headers(&request);
let response = format!(
"HTTP/1.1 204 No Content\r\n{cors_headers}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = stream.write_all(response.as_bytes()).await;
return;
}
let response = format!(
"HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
@@ -69,13 +215,28 @@ pub(super) async fn handle_http_request(
}
if method == "POST" {
if path == "/api/command" && !is_same_origin_command_request(&request) {
write_json_error_response_no_cors(
&mut stream,
"403 Forbidden",
"Origin or Referer does not match Host header.",
)
.await;
return;
}
let full_body = read_full_body(&mut stream, peeked).await;
if full_body.is_none()
&& (path == "/api/chat" || path == "/api/sessions" || path == "/api/command")
{
let body = r#"{"error":"Request body too large"}"#;
let cors_headers = if path == "/api/command" {
command_cors_headers(&request)
} else {
CORS_HEADERS.to_string()
};
let response = format!(
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
@@ -117,8 +278,9 @@ pub(super) async fn handle_http_request(
),
),
};
let cors_headers = command_cors_headers(&request);
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
resp_body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
@@ -313,3 +475,241 @@ pub(super) fn serve_embedded_file(url_path: &str) -> (&'static str, &'static str
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
async fn send_request_to_handler(request: &str, session_name: &str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let peeked = request.as_bytes().to_vec();
let last_tabs = Arc::new(RwLock::new(Vec::new()));
let last_engine = Arc::new(RwLock::new("chrome".to_string()));
let session_name = session_name.to_string();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_http_request(stream, &peeked, &last_tabs, &last_engine, &session_name).await;
});
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
client.write_all(request.as_bytes()).await.unwrap();
client.shutdown().await.unwrap();
let mut response = Vec::new();
client.read_to_end(&mut response).await.unwrap();
server.await.unwrap();
String::from_utf8(response).unwrap()
}
#[cfg(unix)]
async fn spawn_fake_daemon(
socket_dir: &std::path::Path,
session_name: &str,
) -> oneshot::Receiver<String> {
let socket_path = socket_dir.join(format!("{session_name}.sock"));
let _ = std::fs::remove_file(&socket_path);
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = tokio::io::BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let mut stream = reader.into_inner();
stream
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
.await
.unwrap();
stream.write_all(b"\n").await.unwrap();
let _ = tx.send(line);
});
rx
}
#[cfg(unix)]
#[tokio::test(flavor = "current_thread")]
async fn cross_origin_command_post_is_rejected_without_relaying_to_daemon() {
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("t");
std::fs::create_dir_all(&temp_parent).unwrap();
let socket_dir = tempfile::Builder::new()
.prefix("ab-")
.tempdir_in(temp_parent)
.unwrap();
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
guard.set(
"AGENT_BROWSER_SOCKET_DIR",
socket_dir.path().to_str().unwrap(),
);
guard.remove("XDG_RUNTIME_DIR");
let session_name = "x";
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
let body = r#"{"action":"tabs"}"#;
let request = format!(
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_request_to_handler(&request, session_name).await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected response: {response}"
);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), daemon_command)
.await
.is_err(),
"cross-origin request reached daemon command relay"
);
}
#[tokio::test(flavor = "current_thread")]
async fn cross_origin_command_preflight_is_rejected_without_wildcard_cors() {
let request = concat!(
"OPTIONS /api/command HTTP/1.1\r\n",
"Host: localhost:7777\r\n",
"Origin: https://evil.example\r\n",
"Access-Control-Request-Method: POST\r\n",
"Access-Control-Request-Headers: content-type\r\n",
"\r\n"
);
let response = send_request_to_handler(request, "x").await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected response: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"forbidden command preflight exposed wildcard CORS: {response}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn command_post_without_origin_or_referer_is_rejected() {
let body = r#"{"action":"tabs"}"#;
let request = format!(
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_request_to_handler(&request, "x").await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected response: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"forbidden command response exposed wildcard CORS: {response}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn command_post_with_dns_rebinding_host_is_rejected() {
let body = r#"{"action":"tabs"}"#;
let request = format!(
"POST /api/command HTTP/1.1\r\nHost: attacker.example:7777\r\nOrigin: http://attacker.example:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_request_to_handler(&request, "x").await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected response: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"forbidden command response exposed wildcard CORS: {response}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn command_post_ignores_header_like_body_lines() {
let body = "Referer: http://localhost:7777\r\n{\"action\":\"tabs\"}";
let request = format!(
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_request_to_handler(&request, "x").await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected response: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"forbidden command response exposed wildcard CORS: {response}"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "current_thread")]
async fn same_origin_command_post_relays_without_wildcard_cors() {
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("t");
std::fs::create_dir_all(&temp_parent).unwrap();
let socket_dir = tempfile::Builder::new()
.prefix("ab-")
.tempdir_in(temp_parent)
.unwrap();
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
guard.set(
"AGENT_BROWSER_SOCKET_DIR",
socket_dir.path().to_str().unwrap(),
);
guard.remove("XDG_RUNTIME_DIR");
let session_name = "x";
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
let body = r#"{"action":"tabs"}"#;
let request = format!(
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: http://localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let response = send_request_to_handler(&request, session_name).await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"unexpected response: {response}"
);
assert!(
response.contains("Access-Control-Allow-Origin: http://localhost:7777"),
"same-origin command response did not reflect origin: {response}"
);
assert!(
!response.contains("Access-Control-Allow-Origin: *"),
"same-origin command response exposed wildcard CORS: {response}"
);
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
.await
.unwrap()
.unwrap();
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
}
}
+7 -1
View File
@@ -1583,6 +1583,8 @@ 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.
Headless Chromium screenshots hide native scrollbars for consistent image output.
Pass --hide-scrollbars false when launching to keep native scrollbars visible.
Options:
--full, -f Capture full page (not just viewport)
@@ -3098,6 +3100,8 @@ Options:
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)
--hide-scrollbars <bool> Hide native scrollbars in headless Chromium screenshots (default: true)
Use --hide-scrollbars false to keep scrollbars visible
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
--device <name> iOS device name (e.g., "iPhone 15 Pro")
--json JSON output
@@ -3137,11 +3141,12 @@ Configuration:
Boolean flags accept an optional true/false value to override config:
--headed (same as --headed true)
--headed false (disables "headed": true from config)
--hide-scrollbars false (keeps native scrollbars visible in headless Chromium screenshots)
Extensions from user and project configs are merged (not replaced).
Example agent-browser.json:
{{"headed": true, "proxy": "http://localhost:8080", "profile": "./browser-data"}}
{{"headed": true, "hideScrollbars": false, "proxy": "http://localhost:8080"}}
Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config)
@@ -3161,6 +3166,7 @@ Environment:
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_HIDE_SCROLLBARS Hide scrollbars in headless Chromium screenshots (default: true)
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 action timeout in ms (default: 25000)
+25 -8
View File
@@ -20,13 +20,19 @@ services:
# Build both targets in parallel
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
PID1=$!
PID1=$$!
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
PID2=$!
PID2=$$!
# Wait for both to complete
wait $PID1 $PID2
# Wait for both and check exit codes individually — without this
# the outer script exits 0 even if one of the parallel builds
# failed, silently leaving a stale binary in /output from the
# previous release. Caused 0.27.0-fork.5 to ship with a stale
# linux-x64 binary at the first publish attempt until caught
# manually by checking the embedded version string.
wait $$PID1 || { echo "✗ Linux x64 build failed"; exit 1; }
wait $$PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; }
echo ""
echo "✓ Linux platforms built successfully!"
@@ -65,10 +71,21 @@ services:
environment:
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
# NOTE: $$ escapes a literal $ for the in-container shell. A single $ is
# interpolated by docker compose at YAML parse time against the *host*
# environment, which silently drops script-local variables like SRC
# (caused 0.27.0-fork.7 to ship with a stale linux-arm64 binary because
# the cp command resolved to `cp "" "/output/"` after compose ate $SRC
# and $OUTPUT_NAME). $TARGET / $OUTPUT_NAME are set via `environment:`
# below — those are also passed into the container, so $$TARGET and
# $$OUTPUT_NAME read them at script time.
command: |
-c '
cargo zigbuild --release --target $TARGET
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
echo "✓ Built $OUTPUT_NAME"
set -e
cargo zigbuild --release --target $$TARGET
SRC="/build/target/$$TARGET/release/agent-browser"
if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
cp "$$SRC" "/output/$$OUTPUT_NAME"
chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
echo "✓ Built $$OUTPUT_NAME"
'
+7 -6
View File
@@ -1,8 +1,9 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.1",
"version": "0.27.0-fork.9",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
"files": [
"bin",
"scripts",
@@ -11,9 +12,9 @@
"extensions"
],
"bin": {
"agent-browser-stealth": "./bin/agent-browser.js",
"agent-browser": "./bin/agent-browser.js",
"abs": "./bin/agent-browser.js"
"agent-browser-stealth": "bin/agent-browser.js",
"agent-browser": "bin/agent-browser.js",
"abs": "bin/agent-browser.js"
},
"scripts": {
"prepare": "husky",
@@ -21,9 +22,9 @@
"version": "npm run version:sync && git add cli/Cargo.toml",
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
"build:macos": "npm run version:sync && bash -c 'cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & PID1=$!; cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1' && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
"build:all-platforms": "npm run version:sync && npm run build:linux && npm run build:windows && npm run build:macos",
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
"postinstall": "node scripts/postinstall.js"
+7 -11148
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1,2 +1,9 @@
packages:
- '.'
minimumReleaseAge: 2880
allowBuilds:
'@mongodb-js/zstd': false
msw: false
node-liblzma: false
sharp: false
unrs-resolver: false
+3
View File
@@ -243,6 +243,9 @@ agent-browser screenshot --full full.png # full scroll height
agent-browser screenshot --annotate map.png # numbered labels + legend keyed to snapshot refs
```
Headless Chromium screenshots hide native scrollbars for consistent image output.
Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
`--annotate` is designed for multimodal models: each label `[N]` maps to ref `@eN`.
### Handle multiple pages via tabs
+5
View File
@@ -103,6 +103,9 @@ agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
Headless Chromium screenshots hide native scrollbars for consistent image output.
Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
## Video Recording
```bash
@@ -309,6 +312,7 @@ agent-browser --headers <json> ... # HTTP headers scoped to URL's origin
agent-browser --executable-path <p> # Custom browser executable
agent-browser --extension <path> ... # Load browser extension (repeatable)
agent-browser --ignore-https-errors # Ignore SSL certificate errors
agent-browser --hide-scrollbars false # Keep native scrollbars visible in headless Chromium screenshots
agent-browser --help # Show help (-h)
agent-browser --version # Show version (-V)
agent-browser <command> --help # Show detailed help for a command
@@ -383,6 +387,7 @@ AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
AGENT_BROWSER_INIT_SCRIPTS="/a.js,/b.js" # Comma-separated init script paths
AGENT_BROWSER_ENABLE="react-devtools" # Comma-separated built-in init script features
AGENT_BROWSER_HIDE_SCROLLBARS="false" # Keep native scrollbars visible in headless Chromium screenshots
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location