Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b2f5a192 | ||
|
|
7a1ca90416 | ||
|
|
ad0fb424c3 | ||
|
|
f62e204038 | ||
|
|
6f4e63ba91 | ||
|
|
98622a7415 | ||
|
|
3d032f9e88 | ||
|
|
d027659571 | ||
|
|
44b6218ef9 | ||
|
|
e93acc68f8 | ||
|
|
d2a33cc005 | ||
|
|
c26afbaba6 | ||
|
|
ffb386e3af | ||
|
|
947d150561 | ||
|
|
06a29251a2 | ||
|
|
0eacec9b9f | ||
|
|
7159012173 | ||
|
|
1b3d41e579 | ||
|
|
dbf272ced7 | ||
|
|
64140879d5 | ||
|
|
d3bfd76c96 | ||
|
|
47dfe760be | ||
|
|
0db6604105 | ||
|
|
007fd1b27f |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.3"
|
||||
version = "0.27.0-fork.10"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.3"
|
||||
version = "0.27.0-fork.10"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1066,6 +1066,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// === Get ===
|
||||
"get" => parse_get(&rest, &id),
|
||||
|
||||
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
||||
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
|
||||
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
||||
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value"
|
||||
| "count" | "box" | "styles" | "attr" => {
|
||||
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
|
||||
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
||||
get_args.push(sub);
|
||||
get_args.extend_from_slice(&rest);
|
||||
parse_get(&get_args, &id)
|
||||
}
|
||||
|
||||
// === Is (state checks) ===
|
||||
"is" => parse_is(&rest, &id),
|
||||
|
||||
@@ -2751,6 +2763,7 @@ mod tests {
|
||||
provider: None,
|
||||
ignore_https_errors: false,
|
||||
allow_file_access: false,
|
||||
hide_scrollbars: true,
|
||||
device: None,
|
||||
auto_connect: false,
|
||||
force_launch: false,
|
||||
@@ -2766,6 +2779,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,
|
||||
|
||||
@@ -412,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>,
|
||||
@@ -476,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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+199
-37
@@ -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()
|
||||
}
|
||||
|
||||
@@ -632,6 +633,8 @@ impl DaemonState {
|
||||
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
||||
.await;
|
||||
}
|
||||
// Hide automation markers in this cross-origin iframe session too.
|
||||
apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
|
||||
}
|
||||
}
|
||||
for sid in &drained.detached_iframe_sessions {
|
||||
@@ -1510,6 +1513,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)
|
||||
}
|
||||
|
||||
@@ -1582,11 +1607,15 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
// Return a helpful error guiding the user to enable it.
|
||||
return Err(format!(
|
||||
"Could not connect to your Chrome browser.\n\n\
|
||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||
Otherwise, 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: remote debugging is a startup flag, not a Chrome setting — \
|
||||
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||
does NOT expose the CDP HTTP API on /json/version. \
|
||||
A full restart with --remote-debugging-port=<port> is required.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
}
|
||||
@@ -1734,45 +1763,62 @@ fn chrome_relaunch_hint() -> &'static str {
|
||||
/// Called after every successful launch / CDP connect / auto-connect.
|
||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
||||
return; // Explicitly disabled
|
||||
}
|
||||
let Some(ref mgr) = state.browser else {
|
||||
return;
|
||||
};
|
||||
let Ok(session_id) = mgr.active_session_id() else {
|
||||
return;
|
||||
};
|
||||
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
|
||||
fn stealth_enabled() -> bool {
|
||||
!env::var("AGENT_BROWSER_STEALTH")
|
||||
.map(|v| v == "0")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
||||
/// Apply stealth patches to ONE CDP session of the given browser.
|
||||
///
|
||||
/// Stealth scripts are registered per-session via
|
||||
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
|
||||
/// tabs or cross-origin iframe sessions created after the initial page. We must
|
||||
/// re-apply to every session the user can touch, otherwise automation markers
|
||||
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
|
||||
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
|
||||
if !stealth_enabled() {
|
||||
return;
|
||||
}
|
||||
// Determine mode: an external attach uses minimal patches (the user's real
|
||||
// Chrome already has a genuine fingerprint — heavy patches create detectable
|
||||
// "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
|
||||
let mode = if mgr.is_cdp_connection() {
|
||||
stealth::StealthMode::CdpAttach
|
||||
} else {
|
||||
stealth::StealthMode::FullLaunch
|
||||
};
|
||||
|
||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||
if let Err(e) = stealth::apply_stealth(
|
||||
&mgr.client,
|
||||
session_id,
|
||||
mode,
|
||||
locale.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
||||
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
|
||||
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
|
||||
}
|
||||
// Also inject into the current page (already loaded before our init script)
|
||||
// Also inject into the current page (already loaded before our init script).
|
||||
if let Err(e) =
|
||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
|
||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
|
||||
.await
|
||||
{
|
||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
||||
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth to a specific session of the active browser (no-op if no
|
||||
/// browser or stealth disabled).
|
||||
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
|
||||
if let Some(ref mgr) = state.browser {
|
||||
apply_stealth_via_mgr(mgr, session_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth to the active page session (initial connect/launch).
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) {
|
||||
Some(sid) => sid.to_string(),
|
||||
None => return,
|
||||
};
|
||||
apply_stealth_to_session(state, &session_id).await;
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1853,11 +1899,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(),
|
||||
@@ -2021,6 +2080,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,
|
||||
};
|
||||
@@ -2110,11 +2170,15 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
Err(_e) => {
|
||||
return Err(format!(
|
||||
"Could not connect to your Chrome browser.\n\n\
|
||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||
Otherwise, 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: remote debugging is a startup flag, not a Chrome setting — \
|
||||
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||
does NOT expose the CDP HTTP API on /json/version. \
|
||||
A full restart with --remote-debugging-port=<port> is required.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
}
|
||||
@@ -3105,6 +3169,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 }));
|
||||
}
|
||||
@@ -3316,6 +3389,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,
|
||||
@@ -3857,13 +3973,26 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||
let url = cmd.get("url").and_then(|v| v.as_str());
|
||||
let label = cmd.get("label").and_then(|v| v.as_str());
|
||||
state.ref_map.clear();
|
||||
state.iframe_sessions.clear();
|
||||
state.active_frame_id = None;
|
||||
mgr.tab_new(url, label).await
|
||||
let result = {
|
||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||
mgr.tab_new(url, label).await?
|
||||
};
|
||||
// A new tab is a new CDP session; stealth scripts registered on the prior
|
||||
// session don't carry over, so patch the new tab too.
|
||||
if let Some(sid) = state
|
||||
.browser
|
||||
.as_ref()
|
||||
.and_then(|m| m.active_session_id().ok())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
apply_stealth_to_session(state, &sid).await;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
@@ -8570,17 +8699,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,
|
||||
@@ -8588,6 +8721,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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
+168
-97
@@ -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)
|
||||
@@ -653,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
|
||||
Some((port, ws_path))
|
||||
}
|
||||
|
||||
/// Remove leftover Chrome temp profile directories from daemons that were
|
||||
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
|
||||
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
|
||||
/// per session under the system temp dir. On daemon startup we sweep them — but
|
||||
/// ONLY dirs that no running process still references as `--user-data-dir`, so
|
||||
/// a profile in active use is never deleted.
|
||||
pub fn cleanup_orphaned_chrome_profiles() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let Ok(entries) = std::fs::read_dir(&tmp) else {
|
||||
return;
|
||||
};
|
||||
// Snapshot live process command lines once. If we can't determine them,
|
||||
// skip cleanup entirely rather than risk deleting an in-use profile.
|
||||
let Some(live_cmdlines) = running_process_cmdlines() else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let path_str = path.to_string_lossy();
|
||||
let in_use = live_cmdlines
|
||||
.iter()
|
||||
.any(|cmd| cmd.contains(path_str.as_ref()));
|
||||
if !in_use {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["-axww", "-o", "command="])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|l| l.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||
// Best-effort: skip cleanup where we can't cheaply enumerate full process
|
||||
// command lines, to avoid deleting a profile that is still in use.
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
let user_data_dirs = get_chrome_user_data_dirs();
|
||||
|
||||
@@ -674,57 +741,61 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
||||
Err("No running Chrome with remote debugging found. Remote debugging is a \
|
||||
startup flag, not a setting: fully quit Chrome and relaunch it with \
|
||||
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
|
||||
--cdp <port>/--launch."
|
||||
.to_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 +1431,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 +1452,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 +1507,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 +1564,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 +1964,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();
|
||||
}
|
||||
|
||||
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
"All CDP discovery methods failed for {host}:{port}. \
|
||||
Note: Chrome 136+ no longer serves the HTTP discovery endpoints \
|
||||
(/json/version, /json/list), so `--cdp <port>` cannot find the target — \
|
||||
use the default auto-connect (just `agent-browser open <url>`), which reads \
|
||||
DevToolsActivePort and attaches over WebSocket. \
|
||||
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ pub async fn run_daemon(session: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't
|
||||
// run on kill -9). Only removes dirs no live process references.
|
||||
super::cdp::chrome::cleanup_orphaned_chrome_profiles();
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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", ¶ms, 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,
|
||||
|
||||
@@ -903,8 +903,15 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
requestAnimationFrame(() => \
|
||||
requestAnimationFrame(() => \
|
||||
queueMicrotask(() => resolve(true)))))";
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
// 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(),
|
||||
@@ -912,8 +919,9 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
await_promise: Some(true),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn dispatch_click(
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
||||
(function(){
|
||||
const removeWebdriver = (target) => {
|
||||
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||
// lie-detection (creepjs). Only intervene when webdriver is still truthy
|
||||
// (e.g. older Chrome without that override) and force it to FALSE.
|
||||
//
|
||||
// Never `delete` webdriver: real Chrome reports `false`, so `undefined` is
|
||||
// itself a tell, and deleting it removes the native `false` the override set.
|
||||
const forceWebdriverFalse = (target) => {
|
||||
if (!target) return;
|
||||
try { delete target.webdriver; } catch {}
|
||||
try {
|
||||
if (target.webdriver === true) {
|
||||
Object.defineProperty(target, 'webdriver', {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
removeWebdriver(navigator);
|
||||
removeWebdriver(Object.getPrototypeOf(navigator));
|
||||
removeWebdriver(Navigator.prototype);
|
||||
forceWebdriverFalse(navigator);
|
||||
forceWebdriverFalse(Object.getPrototypeOf(navigator));
|
||||
forceWebdriverFalse(Navigator.prototype);
|
||||
if (typeof WorkerNavigator !== 'undefined') {
|
||||
removeWebdriver(WorkerNavigator.prototype);
|
||||
forceWebdriverFalse(WorkerNavigator.prototype);
|
||||
}
|
||||
})();
|
||||
(function(){
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -1042,6 +1042,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
|
||||
// Default success
|
||||
println!("{} Done", color::success_indicator());
|
||||
} else {
|
||||
// Success response with no data payload — still confirm the command ran
|
||||
// instead of printing nothing (a silent exit 0 looks like a no-op and
|
||||
// hides whether anything happened).
|
||||
println!("{} Done", color::success_indicator());
|
||||
}
|
||||
|
||||
print_warning(resp);
|
||||
@@ -1583,6 +1588,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 +3105,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 +3146,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 +3171,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)
|
||||
|
||||
@@ -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"
|
||||
'
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.27.0-fork.3",
|
||||
"version": "0.27.0-fork.10",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
"files": [
|
||||
"bin",
|
||||
"scripts",
|
||||
@@ -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"
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
packages:
|
||||
- '.'
|
||||
minimumReleaseAge: 2880
|
||||
allowBuilds:
|
||||
'@mongodb-js/zstd': false
|
||||
msw: false
|
||||
node-liblzma: false
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user