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
22 changed files with 1038 additions and 196 deletions
+7 -4
View File
@@ -15,6 +15,11 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Check version sync - name: Check version sync
run: node scripts/check-version-sync.js run: node scripts/check-version-sync.js
@@ -54,12 +59,10 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version-file: .node-version
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --filter dashboard run: pnpm install --filter dashboard
@@ -209,7 +212,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version-file: .node-version
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
+7 -6
View File
@@ -26,6 +26,11 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: Compare package.json version to npm and check GitHub release
id: check id: check
run: | run: |
@@ -109,13 +114,11 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '24' node-version-file: .node-version
cache: pnpm cache: pnpm
- name: Install npm dependencies - name: Install npm dependencies
@@ -202,13 +205,11 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '24' node-version-file: .node-version
cache: pnpm cache: pnpm
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
+1
View File
@@ -0,0 +1 @@
24
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-browser-stealth" name = "agent-browser-stealth"
version = "0.27.0-fork.7" version = "0.27.0-fork.9"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-browser-stealth" name = "agent-browser-stealth"
version = "0.27.0-fork.7" version = "0.27.0-fork.9"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" license = "Apache-2.0"
+2
View File
@@ -2751,6 +2751,7 @@ mod tests {
provider: None, provider: None,
ignore_https_errors: false, ignore_https_errors: false,
allow_file_access: false, allow_file_access: false,
hide_scrollbars: true,
device: None, device: None,
auto_connect: false, auto_connect: false,
force_launch: false, force_launch: false,
@@ -2766,6 +2767,7 @@ mod tests {
cli_proxy: false, cli_proxy: false,
cli_proxy_bypass: false, cli_proxy_bypass: false,
cli_allow_file_access: false, cli_allow_file_access: false,
cli_hide_scrollbars: false,
cli_annotate: false, cli_annotate: false,
cli_download_path: false, cli_download_path: false,
cli_headed: false, cli_headed: false,
+5
View File
@@ -412,6 +412,7 @@ pub struct DaemonOptions<'a> {
pub proxy_password: Option<&'a str>, pub proxy_password: Option<&'a str>,
pub ignore_https_errors: bool, pub ignore_https_errors: bool,
pub allow_file_access: bool, pub allow_file_access: bool,
pub hide_scrollbars: bool,
pub profile: Option<&'a str>, pub profile: Option<&'a str>,
pub state: Option<&'a str>, pub state: Option<&'a str>,
pub provider: Option<&'a str>, pub provider: Option<&'a str>,
@@ -476,6 +477,10 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
if opts.allow_file_access { if opts.allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); 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 { if let Some(prof) = opts.profile {
cmd.env("AGENT_BROWSER_PROFILE", prof); cmd.env("AGENT_BROWSER_PROFILE", prof);
} }
+1
View File
@@ -65,6 +65,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
proxy_password: None, proxy_password: None,
ignore_https_errors: false, ignore_https_errors: false,
allow_file_access: false, allow_file_access: false,
hide_scrollbars: true,
profile: None, profile: None,
state: None, state: None,
provider: None, provider: None,
+56 -1
View File
@@ -70,6 +70,7 @@ pub struct Config {
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub provider: Option<String>, pub provider: Option<String>,
pub device: Option<String>, pub device: Option<String>,
pub hide_scrollbars: Option<bool>,
pub ignore_https_errors: Option<bool>, pub ignore_https_errors: Option<bool>,
pub allow_file_access: Option<bool>, pub allow_file_access: Option<bool>,
pub cdp: Option<String>, pub cdp: Option<String>,
@@ -131,6 +132,7 @@ impl Config {
user_agent: other.user_agent.or(self.user_agent), user_agent: other.user_agent.or(self.user_agent),
provider: other.provider.or(self.provider), provider: other.provider.or(self.provider),
device: other.device.or(self.device), 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), ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
allow_file_access: other.allow_file_access.or(self.allow_file_access), allow_file_access: other.allow_file_access.or(self.allow_file_access),
cdp: other.cdp.or(self.cdp), 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). /// 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. /// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) { fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
@@ -306,6 +314,7 @@ pub struct Flags {
pub provider: Option<String>, pub provider: Option<String>,
pub ignore_https_errors: bool, pub ignore_https_errors: bool,
pub allow_file_access: bool, pub allow_file_access: bool,
pub hide_scrollbars: bool,
pub device: Option<String>, pub device: Option<String>,
pub auto_connect: bool, pub auto_connect: bool,
pub force_launch: bool, pub force_launch: bool,
@@ -343,6 +352,7 @@ pub struct Flags {
pub cli_proxy: bool, pub cli_proxy: bool,
pub cli_proxy_bypass: bool, pub cli_proxy_bypass: bool,
pub cli_allow_file_access: bool, pub cli_allow_file_access: bool,
pub cli_hide_scrollbars: bool,
pub cli_annotate: bool, pub cli_annotate: bool,
pub cli_download_path: bool, pub cli_download_path: bool,
pub cli_headed: bool, pub cli_headed: bool,
@@ -443,6 +453,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|| config.ignore_https_errors.unwrap_or(false), || config.ignore_https_errors.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS") allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false), || 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), device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT") auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
&& (env_var_is_truthy("AGENT_BROWSER_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: false,
cli_proxy_bypass: false, cli_proxy_bypass: false,
cli_allow_file_access: false, cli_allow_file_access: false,
cli_hide_scrollbars: false,
cli_annotate: false, cli_annotate: false,
cli_download_path: false, cli_download_path: false,
cli_headed: false, cli_headed: false,
@@ -677,6 +691,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; 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" => { "--device" => {
if let Some(d) = args.get(i + 1) { if let Some(d) = args.get(i + 1) {
flags.device = Some(d.clone()); flags.device = Some(d.clone());
@@ -852,6 +874,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--debug", "--debug",
"--ignore-https-errors", "--ignore-https-errors",
"--allow-file-access", "--allow-file-access",
"--hide-scrollbars",
"--auto-connect", "--auto-connect",
"--launch", "--launch",
"--new", "--new",
@@ -933,6 +956,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::test_utils::EnvGuard;
fn args(s: &str) -> Vec<String> { fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect() s.split_whitespace().map(String::from).collect()
@@ -1176,6 +1200,7 @@ mod tests {
"userAgent": "test-agent", "userAgent": "test-agent",
"provider": "ios", "provider": "ios",
"device": "iPhone 15", "device": "iPhone 15",
"hideScrollbars": false,
"ignoreHttpsErrors": true, "ignoreHttpsErrors": true,
"allowFileAccess": true, "allowFileAccess": true,
"cdp": "9222", "cdp": "9222",
@@ -1201,6 +1226,7 @@ mod tests {
assert_eq!(config.user_agent.as_deref(), Some("test-agent")); assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
assert_eq!(config.provider.as_deref(), Some("ios")); assert_eq!(config.provider.as_deref(), Some("ios"));
assert_eq!(config.device.as_deref(), Some("iPhone 15")); 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.ignore_https_errors, Some(true));
assert_eq!(config.allow_file_access, Some(true)); assert_eq!(config.allow_file_access, Some(true));
assert_eq!(config.cdp.as_deref(), Some("9222")); assert_eq!(config.cdp.as_deref(), Some("9222"));
@@ -1454,6 +1480,33 @@ mod tests {
assert!(flags.cli_allow_file_access); 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] #[test]
fn test_auto_connect_false() { fn test_auto_connect_false() {
let flags = parse_flags(&args("--auto-connect false open")); let flags = parse_flags(&args("--auto-connect false open"));
@@ -1462,7 +1515,9 @@ mod tests {
#[test] #[test]
fn test_clean_args_removes_bool_flag_with_value() { 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"]); assert_eq!(cleaned, vec!["open", "example.com"]);
} }
+48
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 { struct ParsedProxy {
server: String, server: String,
username: Option<String>, username: Option<String>,
@@ -741,6 +758,7 @@ fn main() {
proxy_password: proxy_password.as_deref(), proxy_password: proxy_password.as_deref(),
ignore_https_errors: flags.ignore_https_errors, ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access, allow_file_access: flags.allow_file_access,
hide_scrollbars: flags.hide_scrollbars,
profile: flags.profile.as_deref(), profile: flags.profile.as_deref(),
state: flags.state.as_deref(), state: flags.state.as_deref(),
provider: flags.provider.as_deref(), provider: flags.provider.as_deref(),
@@ -814,6 +832,7 @@ fn main() {
}, },
flags.ignore_https_errors.then_some("--ignore-https-errors"), flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"), 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_download_path.then_some("--download-path"),
flags.cli_headed.then_some("--headed"), flags.cli_headed.then_some("--headed"),
] ]
@@ -1062,6 +1081,10 @@ fn main() {
|| flags.args.is_some() || flags.args.is_some()
|| flags.user_agent.is_some() || flags.user_agent.is_some()
|| flags.allow_file_access || flags.allow_file_access
|| should_send_hide_scrollbars_launch_option(
flags.cli_hide_scrollbars,
flags.hide_scrollbars,
)
|| flags.color_scheme.is_some() || flags.color_scheme.is_some()
|| flags.download_path.is_some() || flags.download_path.is_some()
|| flags.engine.is_some() || flags.engine.is_some()
@@ -1136,6 +1159,12 @@ fn main() {
launch_cmd["allowFileAccess"] = json!(true); 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 { if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs); launch_cmd["colorScheme"] = json!(cs);
} }
@@ -1488,4 +1517,23 @@ mod tests {
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m" "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);
}
} }
+51 -3
View File
@@ -197,6 +197,7 @@ fn launch_hash(opts: &LaunchOptions) -> u64 {
opts.proxy_password.hash(&mut h); opts.proxy_password.hash(&mut h);
opts.user_agent.hash(&mut h); opts.user_agent.hash(&mut h);
opts.allow_file_access.hash(&mut h); opts.allow_file_access.hash(&mut h);
opts.hide_scrollbars.hash(&mut h);
h.finish() h.finish()
} }
@@ -1876,11 +1877,24 @@ fn launch_options_from_env() -> LaunchOptions {
.unwrap_or(false), .unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(), download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
hide_scrollbars: hide_scrollbars_from_env(),
viewport_size: None, viewport_size: None,
use_real_keychain: false, 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) { async fn try_auto_restore_state(state: &mut DaemonState) {
let session_name = match state.session_name.as_deref() { let session_name = match state.session_name.as_deref() {
Some(n) if !n.is_empty() => n.to_string(), Some(n) if !n.is_empty() => n.to_string(),
@@ -2044,6 +2058,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.get("downloadPath") .get("downloadPath")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
hide_scrollbars: hide_scrollbars_from_launch_cmd(cmd),
viewport_size: None, viewport_size: None,
use_real_keychain: false, use_real_keychain: false,
}; };
@@ -8646,17 +8661,21 @@ mod tests {
#[test] #[test]
fn test_launch_options_from_env_defaults() { 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(); let opts = launch_options_from_env();
assert!(opts.headless); assert!(opts.headless);
assert!(opts.args.is_empty()); assert!(opts.args.is_empty());
assert!(!opts.allow_file_access); assert!(!opts.allow_file_access);
assert!(opts.hide_scrollbars);
} }
#[test] #[test]
fn test_launch_options_from_env_headed_flag() { fn test_launch_options_from_env_headed_flag() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]); let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
_guard.set("AGENT_BROWSER_HEADED", "1"); guard.set("AGENT_BROWSER_HEADED", "1");
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let opts = launch_options_from_env(); let opts = launch_options_from_env();
assert!( assert!(
!opts.headless, !opts.headless,
@@ -8664,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] #[test]
fn test_har_entry_to_json_enriches_request_and_response() { fn test_har_entry_to_json_enriches_request_and_response() {
// wall_time: 2026-03-15T12:00:00Z = 1_773_576_000 // 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_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10); 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 { impl BrowserManager {
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> { pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
let engine = engine.unwrap_or("chrome"); let engine = engine.unwrap_or("chrome");
@@ -829,21 +865,27 @@ impl BrowserManager {
self.default_timeout_ms self.default_timeout_ms
} }
/// Checks if the CDP connection is alive by sending a simple command. /// Checks if the CDP connection is alive by sending a `Browser.getVersion`
/// Returns false if the command times out or fails. /// 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 { pub async fn is_connection_alive(&self) -> bool {
let timeout = tokio::time::Duration::from_secs(3); let timeout = tokio::time::Duration::from_secs(3);
let result = tokio::time::timeout( let probe = match tokio::time::timeout(
timeout, timeout,
self.client self.client
.send_command_no_params("Browser.getVersion", None), .send_command_no_params("Browser.getVersion", None),
) )
.await; .await
{
match result { Ok(Ok(_)) => LivenessProbe::Responded,
Ok(Ok(_)) => true, Ok(Err(_)) => LivenessProbe::TransportError,
Ok(Err(_)) | Err(_) => false, 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 /// Non-blocking check whether the locally-launched browser process has exited
@@ -1728,6 +1770,35 @@ mod tests {
assert_eq!(format_tab_id(42), "t42"); 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] #[test]
fn test_parse_tab_ref_id() { fn test_parse_tab_ref_id() {
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1))); 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 ignore_https_errors: bool,
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
pub download_path: 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 /// Initial viewport dimensions used for `--window-size` so the content
/// area matches the desired viewport from the start. /// area matches the desired viewport from the start.
pub viewport_size: Option<(u32, u32)>, pub viewport_size: Option<(u32, u32)>,
@@ -130,6 +133,7 @@ impl Default for LaunchOptions {
ignore_https_errors: false, ignore_https_errors: false,
color_scheme: None, color_scheme: None,
download_path: None, download_path: None,
hide_scrollbars: true,
viewport_size: None, viewport_size: None,
use_real_keychain: false, 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. // injected in headless mode). Skip --headless when extensions are loaded.
if options.headless && !has_extensions { if options.headless && !has_extensions {
args.push("--headless=new".to_string()); 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 // Enable SwiftShader software rendering in headless mode. This
// prevents silent crashes in environments where GPU drivers are // prevents silent crashes in environments where GPU drivers are
// missing or restricted (VMs, containers, some cloud machines) // 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. /// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
/// ///
/// Tries the exact WebSocket path from DevToolsActivePort first (single /// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only
/// prompt on M144+), then falls back to legacy HTTP discovery for older /// by a consent-free TCP liveness check. Falls back to HTTP discovery on the
/// Chrome versions. This order avoids triggering duplicate remote-debugging /// same port for older Chrome layouts.
/// permission prompts (#1210, #1206). ///
/// 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> { 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); // Consent-free liveness: a bare TCP connect does not trigger the
if verify_ws_endpoint(&ws_url).await { // remote-debugging consent flow (that fires on the CDP/WebSocket upgrade),
return Ok(ws_url); // 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 { if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
return Ok(ws_url); return Ok(ws_url);
} }
Err(format!( 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 port
)) ))
} }
/// Verify that a WebSocket endpoint is a live CDP server by sending /// Consent-free check that something is accepting TCP connections on
/// `Browser.getVersion` and checking for a valid response. /// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not
async fn verify_ws_endpoint(ws_url: &str) -> bool { /// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to
use futures_util::{SinkExt, StreamExt}; /// use for liveness before handing the URL to the single real connection.
use tokio_tungstenite::tungstenite::Message; async fn tcp_port_alive(port: u16) -> bool {
let timeout = Duration::from_secs(1);
let timeout = Duration::from_secs(2); matches!(
let result = tokio::time::timeout(timeout, async { tokio::time::timeout(
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?; timeout,
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#; tokio::net::TcpStream::connect(("127.0.0.1", port)),
ws.send(Message::Text(cmd.into())).await.ok()?; )
while let Some(Ok(msg)) = ws.next().await { .await,
if let Message::Text(text) = msg { Ok(Ok(_))
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(())))
} }
/// Returns the default Chrome user-data directory paths for the current platform. /// 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(); let result = build_chrome_args(&opts).unwrap();
assert!(result.args.iter().any(|a| a == "--headless=new")); assert!(result.args.iter().any(|a| a == "--headless=new"));
assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
assert!(result assert!(result
.args .args
.iter() .iter()
@@ -1380,6 +1392,7 @@ mod tests {
}; };
let result = build_chrome_args(&opts).unwrap(); let result = build_chrome_args(&opts).unwrap();
assert!(!result.args.iter().any(|a| a.contains("--headless"))); assert!(!result.args.iter().any(|a| a.contains("--headless")));
assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
assert!(!result assert!(!result
.args .args
.iter() .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] #[test]
fn test_build_args_start_maximized_suppresses_default_window_size() { fn test_build_args_start_maximized_suppresses_default_window_size() {
let opts = LaunchOptions { let opts = LaunchOptions {
@@ -1474,6 +1504,10 @@ mod tests {
!result.args.iter().any(|a| a.contains("--headless")), !result.args.iter().any(|a| a.contains("--headless")),
"headless flag should be omitted when extensions are present" "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!( assert!(
!result.args.iter().any(|a| a.contains("--window-size")), !result.args.iter().any(|a| a.contains("--window-size")),
"window-size should be omitted when extensions force headed mode" "window-size should be omitted when extensions force headed mode"
@@ -1870,83 +1904,60 @@ mod tests {
// auto_connect_cdp discovery-order tests (#1210, #1206) // auto_connect_cdp discovery-order tests (#1210, #1206)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
/// When DevToolsActivePort provides a ws_path and the port is reachable, /// When the port is live, `resolve_cdp_from_active_port` returns the exact
/// `resolve_cdp_from_active_port` should return the exact ws_path URL /// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT
/// WITHOUT calling HTTP discovery first. /// probe with a verification WebSocket (which would burn Chrome 136+'s
/// per-connection remote-debugging consent on a throwaway socket).
#[tokio::test] #[tokio::test]
async fn test_resolve_cdp_from_active_port_prefers_ws_path() { async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() {
use futures_util::{SinkExt, StreamExt}; // A bound listener makes the port TCP-reachable. We do NOT accept/serve
use tokio_tungstenite::tungstenite::Message as WsMsg; // 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 listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port(); 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 { let result = resolve_cdp_from_active_port(port, ws_path).await;
// accept: verify_ws_endpoint() WebSocket handshake assert!(result.is_ok(), "should succeed when port is live: {:?}", result);
let (stream, _) = listener.accept().await.unwrap(); assert_eq!(
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); result.unwrap(),
if let Some(Ok(WsMsg::Text(text))) = ws.next().await { format!("ws://127.0.0.1:{}{}", port, ws_path),
let req: serde_json::Value = serde_json::from_str(&text).unwrap(); "should return the exact DevToolsActivePort URL untouched"
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
); );
assert_eq!(url, format!("ws://127.0.0.1:{}{}", port, ws_path)); drop(listener);
server.await.unwrap();
} }
/// When the exact ws_path connection fails, `resolve_cdp_from_active_port` /// Regression guard for the consent storm: resolving the URL must only do a
/// should fall back to HTTP discovery. /// 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] #[tokio::test]
async fn test_resolve_cdp_from_active_port_falls_back_to_http_discovery() { async fn test_resolve_cdp_from_active_port_does_not_open_websocket() {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port(); let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move { let server = tokio::spawn(async move {
// 1st accept: verify_ws_endpoint() ws_path probe — reject (just close) let (mut stream, _) = listener.accept().await.unwrap();
let (s1, _) = listener.accept().await.unwrap(); // The liveness check connects then drops without writing anything.
drop(s1); // Assert we receive no WebSocket upgrade bytes (EOF / no data).
let mut buf = [0u8; 128];
// 2nd accept: HTTP /json/version from discover_cdp_url() let read = tokio::time::timeout(
let (mut s2, _) = listener.accept().await.unwrap(); Duration::from_millis(500),
let mut buf = [0u8; 2048]; stream.read(&mut buf),
let _ = s2.read(&mut buf).await; )
let body = format!( .await;
r#"{{"webSocketDebuggerUrl":"ws://127.0.0.1:{}/devtools/browser/fallback-uuid"}}"#, match read {
port 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 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 result = resolve_cdp_from_active_port(port, "/devtools/browser/nonexistent-uuid").await; let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await;
assert!(result.is_ok(), "should fall back to HTTP: {:?}", result); assert_eq!(
let url = result.unwrap(); result.unwrap(),
assert!( format!("ws://127.0.0.1:{}/devtools/browser/abc", port)
url.contains("fallback-uuid"),
"should use HTTP discovery fallback, got: {}",
url
); );
server.await.unwrap(); 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); 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 // 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); 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 // Snapshot with refs and ref-based click
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+74 -52
View File
@@ -485,9 +485,8 @@ async fn verify_click_target(
) -> Result<(), String> { ) -> Result<(), String> {
use serde::Deserialize; use serde::Deserialize;
// Tight 500ms timeout — this is a defensive guard, not critical path. // Resolve once. backendNodeId is stable across renders; only the
// If it can't run quickly, fall through and let the click proceed // element under (x, y) is what changes when an overlay flickers.
// (we're no worse off than the unguarded code path).
let resolve_params = DomResolveNodeParams { let resolve_params = DomResolveNodeParams {
backend_node_id: Some(backend_node_id), backend_node_id: Some(backend_node_id),
node_id: None, node_id: None,
@@ -512,6 +511,35 @@ async fn verify_click_target(
return Ok(()); 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. // function(x, y) { ... } where `this` is the target element.
// Return null → click is safe. // Return null → click is safe.
// Return JSON → describes the occluding element. // Return JSON → describes the occluding element.
@@ -527,51 +555,46 @@ async fn verify_click_target(
text: ((at.textContent||'').trim().slice(0, 60)) \ text: ((at.textContent||'').trim().slice(0, 60)) \
}); \ }); \
}"; }";
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(());
};
let Ok(call_result) = call_resp else {
return Ok(());
};
let value = call_result let mut last_occ: Option<Occluder> = None;
.get("result") for attempt in 0..=max_retries {
.and_then(|r| r.get("value")); if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await;
// null / undefined / missing → safe }
let json_str = match value { let call_params = serde_json::json!({
Some(serde_json::Value::String(s)) => s.clone(), "objectId": object_id,
_ => return Ok(()), "functionDeclaration": function_decl,
}; "arguments": [{"value": x}, {"value": y}],
"returnByValue": true,
#[derive(Deserialize)] });
struct Occluder { let call_fut = client.send_command_typed::<_, serde_json::Value>(
tag: Option<String>, "Runtime.callFunctionOn",
testid: Option<String>, &call_params,
role: Option<String>, Some(session_id),
#[serde(rename = "ariaLabel")] );
aria_label: Option<String>, let Ok(call_resp) =
text: Option<String>, tokio::time::timeout(std::time::Duration::from_millis(500), call_fut).await
reason: Option<String>, 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);
} }
let occ: Occluder = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(_) => return Ok(()),
};
// 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 { if let Some(reason) = occ.reason {
return Err(format!( return Err(format!(
"Ref {} cannot be clicked at its computed position: {}. \ "Ref {} cannot be clicked at its computed position: {}. \
@@ -579,7 +602,6 @@ async fn verify_click_target(
ref_id, reason ref_id, reason
)); ));
} }
let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string()); let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string());
if let Some(t) = occ.testid { if let Some(t) = occ.testid {
desc.push_str(&format!("[testid={}]", t)); desc.push_str(&format!("[testid={}]", t));
@@ -595,13 +617,13 @@ async fn verify_click_target(
desc.push_str(&format!(" text=\"{}\"", t)); desc.push_str(&format!(" text=\"{}\"", t));
} }
} }
let waited_ms = (max_retries as u64) * retry_delay_ms;
Err(format!( Err(format!(
"Ref {} is occluded by {} at the click point. \ "Ref {} is occluded by {} at the click point (still occluded after \
A transient overlay (modal backdrop, mask, sticky banner, etc.) \ {} retries / {}ms). A persistent overlay is in the way \
appeared between snapshot and click. \ re-run snapshot, dismiss the overlay, or set \
Wait for it to clear or re-snapshot, then retry. \ AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass.",
Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass this guard.", ref_id, desc, max_retries, waited_ms,
ref_id, desc
)) ))
} }
+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> { fn parse_origin(peeked: &[u8]) -> Option<String> {
let header_str = std::str::from_utf8(peeked).ok()?; let header_str = std::str::from_utf8(peeked).ok()?;
for line in header_str.lines() { request_header_value(header_str, "origin").map(ToString::to_string)
if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") { }
return Some(line[8..].trim().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( pub(super) async fn handle_http_request(
@@ -61,6 +188,25 @@ pub(super) async fn handle_http_request(
let origin = parse_origin(peeked); let origin = parse_origin(peeked);
if method == "OPTIONS" { 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!( 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" "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 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; let full_body = read_full_body(&mut stream, peeked).await;
if full_body.is_none() if full_body.is_none()
&& (path == "/api/chat" || path == "/api/sessions" || path == "/api/command") && (path == "/api/chat" || path == "/api/sessions" || path == "/api/command")
{ {
let body = r#"{"error":"Request body too large"}"#; 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!( 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() body.len()
); );
let _ = stream.write_all(response.as_bytes()).await; 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!( 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() resp_body.len()
); );
let _ = stream.write_all(response.as_bytes()).await; 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, Captures a screenshot of the current page. If no path is provided,
saves to a temporary directory with a generated filename. 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: Options:
--full, -f Capture full page (not just viewport) --full, -f Capture full page (not just viewport)
@@ -3098,6 +3100,8 @@ Options:
e.g., --proxy-bypass "localhost,*.internal.com" e.g., --proxy-bypass "localhost,*.internal.com"
--ignore-https-errors Ignore HTTPS certificate errors --ignore-https-errors Ignore HTTPS certificate errors
--allow-file-access Allow file:// URLs to access local files (Chromium only) --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 -p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
--device <name> iOS device name (e.g., "iPhone 15 Pro") --device <name> iOS device name (e.g., "iPhone 15 Pro")
--json JSON output --json JSON output
@@ -3137,11 +3141,12 @@ Configuration:
Boolean flags accept an optional true/false value to override config: Boolean flags accept an optional true/false value to override config:
--headed (same as --headed true) --headed (same as --headed true)
--headed false (disables "headed": true from config) --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). Extensions from user and project configs are merged (not replaced).
Example agent-browser.json: Example agent-browser.json:
{{"headed": true, "proxy": "http://localhost:8080", "profile": "./browser-data"}} {{"headed": true, "hideScrollbars": false, "proxy": "http://localhost:8080"}}
Environment: Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config) 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_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, browserless, agentcore)
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome 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_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_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000) AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
+18 -13
View File
@@ -20,10 +20,10 @@ services:
# Build both targets in parallel # 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") & (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") & (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 and check exit codes individually — without this # Wait for both and check exit codes individually — without this
# the outer script exits 0 even if one of the parallel builds # the outer script exits 0 even if one of the parallel builds
@@ -31,8 +31,8 @@ services:
# previous release. Caused 0.27.0-fork.5 to ship with a stale # previous release. Caused 0.27.0-fork.5 to ship with a stale
# linux-x64 binary at the first publish attempt until caught # linux-x64 binary at the first publish attempt until caught
# manually by checking the embedded version string. # manually by checking the embedded version string.
wait $PID1 || { echo "✗ Linux x64 build failed"; exit 1; } wait $$PID1 || { echo "✗ Linux x64 build failed"; exit 1; }
wait $PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; } wait $$PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; }
echo "" echo ""
echo "✓ Linux platforms built successfully!" echo "✓ Linux platforms built successfully!"
@@ -71,16 +71,21 @@ services:
environment: environment:
- TARGET=${TARGET:-x86_64-unknown-linux-gnu} - TARGET=${TARGET:-x86_64-unknown-linux-gnu}
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64} - 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: | command: |
-c ' -c '
set -e set -e
cargo zigbuild --release --target $TARGET cargo zigbuild --release --target $$TARGET
# Copy the binary explicitly. The previous `agent-browser*` glob SRC="/build/target/$$TARGET/release/agent-browser"
# matched the binary AND its `.d` dependency file, which made cp if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
# treat the destination as a directory and silently failed. cp "$$SRC" "/output/$$OUTPUT_NAME"
SRC="/build/target/$TARGET/release/agent-browser" chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
if [ -f "$SRC.exe" ]; then SRC="$SRC.exe"; fi echo "✓ Built $$OUTPUT_NAME"
cp "$SRC" "/output/$OUTPUT_NAME"
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
echo "✓ Built $OUTPUT_NAME"
' '
+4 -3
View File
@@ -1,8 +1,9 @@
{ {
"name": "agent-browser-stealth", "name": "agent-browser-stealth",
"version": "0.27.0-fork.7", "version": "0.27.0-fork.9",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection", "description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module", "type": "module",
"packageManager": "pnpm@11.1.3",
"files": [ "files": [
"bin", "bin",
"scripts", "scripts",
@@ -21,9 +22,9 @@
"version": "npm run version:sync && git add cli/Cargo.toml", "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: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: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: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 .", "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", "release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
"postinstall": "node scripts/postinstall.js" "postinstall": "node scripts/postinstall.js"
+7
View File
@@ -1,2 +1,9 @@
packages: 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 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`. `--annotate` is designed for multimodal models: each label `[N]` maps to ref `@eN`.
### Handle multiple pages via tabs ### 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 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 ## Video Recording
```bash ```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 --executable-path <p> # Custom browser executable
agent-browser --extension <path> ... # Load browser extension (repeatable) agent-browser --extension <path> ... # Load browser extension (repeatable)
agent-browser --ignore-https-errors # Ignore SSL certificate errors 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 --help # Show help (-h)
agent-browser --version # Show version (-V) agent-browser --version # Show version (-V)
agent-browser <command> --help # Show detailed help for a command 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_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
AGENT_BROWSER_INIT_SCRIPTS="/a.js,/b.js" # Comma-separated init script 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_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_PROVIDER="browserbase" # Cloud browser provider
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned) AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location