Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4e63ba91 | ||
|
|
98622a7415 | ||
|
|
3d032f9e88 | ||
|
|
d027659571 | ||
|
|
44b6218ef9 | ||
|
|
e93acc68f8 | ||
|
|
d2a33cc005 | ||
|
|
c26afbaba6 | ||
|
|
ffb386e3af | ||
|
|
947d150561 | ||
|
|
06a29251a2 | ||
|
|
0eacec9b9f | ||
|
|
7159012173 | ||
|
|
1b3d41e579 | ||
|
|
dbf272ced7 | ||
|
|
64140879d5 | ||
|
|
d3bfd76c96 | ||
|
|
47dfe760be | ||
|
|
0db6604105 | ||
|
|
007fd1b27f | ||
|
|
3d1132af90 | ||
|
|
90ba44cd38 | ||
|
|
52f8ead0f2 |
@@ -15,6 +15,11 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
- name: Check version sync
|
- name: Check version sync
|
||||||
run: node scripts/check-version-sync.js
|
run: node scripts/check-version-sync.js
|
||||||
|
|
||||||
@@ -54,12 +59,10 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version-file: .node-version
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --filter dashboard
|
run: pnpm install --filter dashboard
|
||||||
@@ -209,7 +212,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version-file: .node-version
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
- name: Compare package.json version to npm and check GitHub release
|
- name: Compare package.json version to npm and check GitHub release
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
@@ -109,13 +114,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version-file: .node-version
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
@@ -202,13 +205,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version-file: .node-version
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.2"
|
version = "0.27.0-fork.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.2"
|
version = "0.27.0-fork.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+75
-3
@@ -614,17 +614,44 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
return Ok(cmd);
|
return Ok(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --gone / --hidden: wait for an element to leave the DOM or
|
||||||
|
// become invisible. Useful after a click that's supposed to
|
||||||
|
// close a dialog, so the next command fails fast instead of
|
||||||
|
// racing into a half-rendered UI.
|
||||||
|
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
|
||||||
|
Some("detached")
|
||||||
|
} else if rest.iter().any(|&s| s == "--hidden") {
|
||||||
|
Some("hidden")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Default: selector or timeout
|
// Default: selector or timeout
|
||||||
if let Some(arg) = rest.first() {
|
// First non-flag positional is selector or numeric timeout
|
||||||
|
let positional = rest.iter().find(|&&s| !s.starts_with("--"));
|
||||||
|
let timeout_ms = rest
|
||||||
|
.iter()
|
||||||
|
.position(|&s| s == "--timeout")
|
||||||
|
.and_then(|idx| rest.get(idx + 1))
|
||||||
|
.and_then(|s| s.parse::<u64>().ok());
|
||||||
|
|
||||||
|
if let Some(arg) = positional {
|
||||||
if let Ok(timeout) = arg.parse::<u64>() {
|
if let Ok(timeout) = arg.parse::<u64>() {
|
||||||
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
||||||
} else {
|
} else {
|
||||||
Ok(json!({ "id": id, "action": "wait", "selector": arg }))
|
let mut cmd = json!({ "id": id, "action": "wait", "selector": arg });
|
||||||
|
if let Some(state) = state_override {
|
||||||
|
cmd["state"] = json!(state);
|
||||||
|
}
|
||||||
|
if let Some(t) = timeout_ms {
|
||||||
|
cmd["timeout"] = json!(t);
|
||||||
|
}
|
||||||
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ParseError::MissingArguments {
|
Err(ParseError::MissingArguments {
|
||||||
context: "wait".to_string(),
|
context: "wait".to_string(),
|
||||||
usage: "wait <selector|ms|--url|--load|--fn|--text>",
|
usage: "wait <selector|ms> [--gone|--hidden] [--timeout ms]",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2724,6 +2751,7 @@ mod tests {
|
|||||||
provider: None,
|
provider: None,
|
||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
allow_file_access: false,
|
allow_file_access: false,
|
||||||
|
hide_scrollbars: true,
|
||||||
device: None,
|
device: None,
|
||||||
auto_connect: false,
|
auto_connect: false,
|
||||||
force_launch: false,
|
force_launch: false,
|
||||||
@@ -2739,6 +2767,7 @@ mod tests {
|
|||||||
cli_proxy: false,
|
cli_proxy: false,
|
||||||
cli_proxy_bypass: false,
|
cli_proxy_bypass: false,
|
||||||
cli_allow_file_access: false,
|
cli_allow_file_access: false,
|
||||||
|
cli_hide_scrollbars: false,
|
||||||
cli_annotate: false,
|
cli_annotate: false,
|
||||||
cli_download_path: false,
|
cli_download_path: false,
|
||||||
cli_headed: false,
|
cli_headed: false,
|
||||||
@@ -5185,4 +5214,47 @@ mod tests {
|
|||||||
let cmd = parse_command(&args("find role button"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("find role button"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["subaction"], "click");
|
assert_eq!(cmd["subaction"], "click");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === wait --gone / --hidden ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_default_visible() {
|
||||||
|
let cmd = parse_command(&args("wait .toast"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "wait");
|
||||||
|
assert_eq!(cmd["selector"], ".toast");
|
||||||
|
assert!(cmd.get("state").is_none(), "default state stays implicit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_gone_sets_detached_state() {
|
||||||
|
let cmd = parse_command(&args("wait .toast --gone"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["selector"], ".toast");
|
||||||
|
assert_eq!(cmd["state"], "detached");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_hidden_sets_hidden_state() {
|
||||||
|
let cmd = parse_command(&args("wait .toast --hidden"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["state"], "hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_gone_with_timeout() {
|
||||||
|
let cmd = parse_command(
|
||||||
|
&args("wait .modal --gone --timeout 2000"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["selector"], ".modal");
|
||||||
|
assert_eq!(cmd["state"], "detached");
|
||||||
|
assert_eq!(cmd["timeout"], 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_numeric_timeout_still_works() {
|
||||||
|
// `wait 500` keeps meaning "sleep 500ms", not "wait for selector 500"
|
||||||
|
let cmd = parse_command(&args("wait 500"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["timeout"], 500);
|
||||||
|
assert!(cmd.get("selector").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ pub struct DaemonOptions<'a> {
|
|||||||
pub proxy_password: Option<&'a str>,
|
pub proxy_password: Option<&'a str>,
|
||||||
pub ignore_https_errors: bool,
|
pub ignore_https_errors: bool,
|
||||||
pub allow_file_access: bool,
|
pub allow_file_access: bool,
|
||||||
|
pub hide_scrollbars: bool,
|
||||||
pub profile: Option<&'a str>,
|
pub profile: Option<&'a str>,
|
||||||
pub state: Option<&'a str>,
|
pub state: Option<&'a str>,
|
||||||
pub provider: Option<&'a str>,
|
pub provider: Option<&'a str>,
|
||||||
@@ -476,6 +477,10 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
|||||||
if opts.allow_file_access {
|
if opts.allow_file_access {
|
||||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||||
}
|
}
|
||||||
|
cmd.env(
|
||||||
|
"AGENT_BROWSER_HIDE_SCROLLBARS",
|
||||||
|
if opts.hide_scrollbars { "1" } else { "0" },
|
||||||
|
);
|
||||||
if let Some(prof) = opts.profile {
|
if let Some(prof) = opts.profile {
|
||||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
|
|||||||
proxy_password: None,
|
proxy_password: None,
|
||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
allow_file_access: false,
|
allow_file_access: false,
|
||||||
|
hide_scrollbars: true,
|
||||||
profile: None,
|
profile: None,
|
||||||
state: None,
|
state: None,
|
||||||
provider: None,
|
provider: None,
|
||||||
|
|||||||
+56
-1
@@ -70,6 +70,7 @@ pub struct Config {
|
|||||||
pub user_agent: Option<String>,
|
pub user_agent: Option<String>,
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
pub device: Option<String>,
|
pub device: Option<String>,
|
||||||
|
pub hide_scrollbars: Option<bool>,
|
||||||
pub ignore_https_errors: Option<bool>,
|
pub ignore_https_errors: Option<bool>,
|
||||||
pub allow_file_access: Option<bool>,
|
pub allow_file_access: Option<bool>,
|
||||||
pub cdp: Option<String>,
|
pub cdp: Option<String>,
|
||||||
@@ -131,6 +132,7 @@ impl Config {
|
|||||||
user_agent: other.user_agent.or(self.user_agent),
|
user_agent: other.user_agent.or(self.user_agent),
|
||||||
provider: other.provider.or(self.provider),
|
provider: other.provider.or(self.provider),
|
||||||
device: other.device.or(self.device),
|
device: other.device.or(self.device),
|
||||||
|
hide_scrollbars: other.hide_scrollbars.or(self.hide_scrollbars),
|
||||||
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
|
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
|
||||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||||
cdp: other.cdp.or(self.cdp),
|
cdp: other.cdp.or(self.cdp),
|
||||||
@@ -187,6 +189,12 @@ fn env_var_is_truthy(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_var_bool(name: &str) -> Option<bool> {
|
||||||
|
env::var(name)
|
||||||
|
.ok()
|
||||||
|
.map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
|
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
|
||||||
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
|
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
|
||||||
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
||||||
@@ -306,6 +314,7 @@ pub struct Flags {
|
|||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
pub ignore_https_errors: bool,
|
pub ignore_https_errors: bool,
|
||||||
pub allow_file_access: bool,
|
pub allow_file_access: bool,
|
||||||
|
pub hide_scrollbars: bool,
|
||||||
pub device: Option<String>,
|
pub device: Option<String>,
|
||||||
pub auto_connect: bool,
|
pub auto_connect: bool,
|
||||||
pub force_launch: bool,
|
pub force_launch: bool,
|
||||||
@@ -343,6 +352,7 @@ pub struct Flags {
|
|||||||
pub cli_proxy: bool,
|
pub cli_proxy: bool,
|
||||||
pub cli_proxy_bypass: bool,
|
pub cli_proxy_bypass: bool,
|
||||||
pub cli_allow_file_access: bool,
|
pub cli_allow_file_access: bool,
|
||||||
|
pub cli_hide_scrollbars: bool,
|
||||||
pub cli_annotate: bool,
|
pub cli_annotate: bool,
|
||||||
pub cli_download_path: bool,
|
pub cli_download_path: bool,
|
||||||
pub cli_headed: bool,
|
pub cli_headed: bool,
|
||||||
@@ -443,6 +453,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
|| config.ignore_https_errors.unwrap_or(false),
|
|| config.ignore_https_errors.unwrap_or(false),
|
||||||
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
||||||
|| config.allow_file_access.unwrap_or(false),
|
|| config.allow_file_access.unwrap_or(false),
|
||||||
|
hide_scrollbars: env_var_bool("AGENT_BROWSER_HIDE_SCROLLBARS")
|
||||||
|
.or(config.hide_scrollbars)
|
||||||
|
.unwrap_or(true),
|
||||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||||
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
||||||
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||||
@@ -518,6 +531,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
cli_proxy: false,
|
cli_proxy: false,
|
||||||
cli_proxy_bypass: false,
|
cli_proxy_bypass: false,
|
||||||
cli_allow_file_access: false,
|
cli_allow_file_access: false,
|
||||||
|
cli_hide_scrollbars: false,
|
||||||
cli_annotate: false,
|
cli_annotate: false,
|
||||||
cli_download_path: false,
|
cli_download_path: false,
|
||||||
cli_headed: false,
|
cli_headed: false,
|
||||||
@@ -677,6 +691,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--hide-scrollbars" => {
|
||||||
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
|
flags.hide_scrollbars = val;
|
||||||
|
flags.cli_hide_scrollbars = true;
|
||||||
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
"--device" => {
|
"--device" => {
|
||||||
if let Some(d) = args.get(i + 1) {
|
if let Some(d) = args.get(i + 1) {
|
||||||
flags.device = Some(d.clone());
|
flags.device = Some(d.clone());
|
||||||
@@ -852,6 +874,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--debug",
|
"--debug",
|
||||||
"--ignore-https-errors",
|
"--ignore-https-errors",
|
||||||
"--allow-file-access",
|
"--allow-file-access",
|
||||||
|
"--hide-scrollbars",
|
||||||
"--auto-connect",
|
"--auto-connect",
|
||||||
"--launch",
|
"--launch",
|
||||||
"--new",
|
"--new",
|
||||||
@@ -933,6 +956,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::test_utils::EnvGuard;
|
||||||
|
|
||||||
fn args(s: &str) -> Vec<String> {
|
fn args(s: &str) -> Vec<String> {
|
||||||
s.split_whitespace().map(String::from).collect()
|
s.split_whitespace().map(String::from).collect()
|
||||||
@@ -1176,6 +1200,7 @@ mod tests {
|
|||||||
"userAgent": "test-agent",
|
"userAgent": "test-agent",
|
||||||
"provider": "ios",
|
"provider": "ios",
|
||||||
"device": "iPhone 15",
|
"device": "iPhone 15",
|
||||||
|
"hideScrollbars": false,
|
||||||
"ignoreHttpsErrors": true,
|
"ignoreHttpsErrors": true,
|
||||||
"allowFileAccess": true,
|
"allowFileAccess": true,
|
||||||
"cdp": "9222",
|
"cdp": "9222",
|
||||||
@@ -1201,6 +1226,7 @@ mod tests {
|
|||||||
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
|
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
|
||||||
assert_eq!(config.provider.as_deref(), Some("ios"));
|
assert_eq!(config.provider.as_deref(), Some("ios"));
|
||||||
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
|
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
|
||||||
|
assert_eq!(config.hide_scrollbars, Some(false));
|
||||||
assert_eq!(config.ignore_https_errors, Some(true));
|
assert_eq!(config.ignore_https_errors, Some(true));
|
||||||
assert_eq!(config.allow_file_access, Some(true));
|
assert_eq!(config.allow_file_access, Some(true));
|
||||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||||
@@ -1454,6 +1480,33 @@ mod tests {
|
|||||||
assert!(flags.cli_allow_file_access);
|
assert!(flags.cli_allow_file_access);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hide_scrollbars_default_true() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||||
|
let flags = parse_flags(&args("open example.com"));
|
||||||
|
assert!(flags.hide_scrollbars);
|
||||||
|
assert!(!flags.cli_hide_scrollbars);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hide_scrollbars_false() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||||
|
let flags = parse_flags(&args("--hide-scrollbars false open"));
|
||||||
|
assert!(!flags.hide_scrollbars);
|
||||||
|
assert!(flags.cli_hide_scrollbars);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hide_scrollbars_bare_defaults_true() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||||
|
let flags = parse_flags(&args("--hide-scrollbars open"));
|
||||||
|
assert!(flags.hide_scrollbars);
|
||||||
|
assert!(flags.cli_hide_scrollbars);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auto_connect_false() {
|
fn test_auto_connect_false() {
|
||||||
let flags = parse_flags(&args("--auto-connect false open"));
|
let flags = parse_flags(&args("--auto-connect false open"));
|
||||||
@@ -1462,7 +1515,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clean_args_removes_bool_flag_with_value() {
|
fn test_clean_args_removes_bool_flag_with_value() {
|
||||||
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
let cleaned = clean_args(&args(
|
||||||
|
"--headed false --debug true --hide-scrollbars false open example.com",
|
||||||
|
));
|
||||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 {
|
struct ParsedProxy {
|
||||||
server: String,
|
server: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
@@ -741,6 +758,7 @@ fn main() {
|
|||||||
proxy_password: proxy_password.as_deref(),
|
proxy_password: proxy_password.as_deref(),
|
||||||
ignore_https_errors: flags.ignore_https_errors,
|
ignore_https_errors: flags.ignore_https_errors,
|
||||||
allow_file_access: flags.allow_file_access,
|
allow_file_access: flags.allow_file_access,
|
||||||
|
hide_scrollbars: flags.hide_scrollbars,
|
||||||
profile: flags.profile.as_deref(),
|
profile: flags.profile.as_deref(),
|
||||||
state: flags.state.as_deref(),
|
state: flags.state.as_deref(),
|
||||||
provider: flags.provider.as_deref(),
|
provider: flags.provider.as_deref(),
|
||||||
@@ -814,6 +832,7 @@ fn main() {
|
|||||||
},
|
},
|
||||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||||
|
flags.cli_hide_scrollbars.then_some("--hide-scrollbars"),
|
||||||
flags.cli_download_path.then_some("--download-path"),
|
flags.cli_download_path.then_some("--download-path"),
|
||||||
flags.cli_headed.then_some("--headed"),
|
flags.cli_headed.then_some("--headed"),
|
||||||
]
|
]
|
||||||
@@ -822,11 +841,24 @@ fn main() {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !ignored_flags.is_empty() && !flags.json {
|
if !ignored_flags.is_empty() && !flags.json {
|
||||||
eprintln!(
|
// Special case: --headed is irrelevant in CDP-attach mode
|
||||||
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
|
// (your existing Chrome is always already visible). The
|
||||||
color::warning_indicator(),
|
// "agent-browser close + reopen" advice doesn't help because
|
||||||
ignored_flags.join(", ")
|
// 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.args.is_some()
|
||||||
|| flags.user_agent.is_some()
|
|| flags.user_agent.is_some()
|
||||||
|| flags.allow_file_access
|
|| flags.allow_file_access
|
||||||
|
|| should_send_hide_scrollbars_launch_option(
|
||||||
|
flags.cli_hide_scrollbars,
|
||||||
|
flags.hide_scrollbars,
|
||||||
|
)
|
||||||
|| flags.color_scheme.is_some()
|
|| flags.color_scheme.is_some()
|
||||||
|| flags.download_path.is_some()
|
|| flags.download_path.is_some()
|
||||||
|| flags.engine.is_some()
|
|| flags.engine.is_some()
|
||||||
@@ -1123,6 +1159,12 @@ fn main() {
|
|||||||
launch_cmd["allowFileAccess"] = json!(true);
|
launch_cmd["allowFileAccess"] = json!(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply_hide_scrollbars_launch_option(
|
||||||
|
&mut launch_cmd,
|
||||||
|
flags.cli_hide_scrollbars,
|
||||||
|
flags.hide_scrollbars,
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(ref cs) = flags.color_scheme {
|
if let Some(ref cs) = flags.color_scheme {
|
||||||
launch_cmd["colorScheme"] = json!(cs);
|
launch_cmd["colorScheme"] = json!(cs);
|
||||||
}
|
}
|
||||||
@@ -1475,4 +1517,23 @@ mod tests {
|
|||||||
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
|
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hide_scrollbars_launch_option_serialization() {
|
||||||
|
assert!(!should_send_hide_scrollbars_launch_option(false, true));
|
||||||
|
assert!(should_send_hide_scrollbars_launch_option(false, false));
|
||||||
|
assert!(should_send_hide_scrollbars_launch_option(true, true));
|
||||||
|
|
||||||
|
let mut default_cmd = json!({ "action": "launch" });
|
||||||
|
apply_hide_scrollbars_launch_option(&mut default_cmd, false, true);
|
||||||
|
assert!(default_cmd.get("hideScrollbars").is_none());
|
||||||
|
|
||||||
|
let mut config_false_cmd = json!({ "action": "launch" });
|
||||||
|
apply_hide_scrollbars_launch_option(&mut config_false_cmd, false, false);
|
||||||
|
assert_eq!(config_false_cmd["hideScrollbars"], false);
|
||||||
|
|
||||||
|
let mut cli_true_cmd = json!({ "action": "launch" });
|
||||||
|
apply_hide_scrollbars_launch_option(&mut cli_true_cmd, true, true);
|
||||||
|
assert_eq!(cli_true_cmd["hideScrollbars"], true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-7
@@ -197,6 +197,7 @@ fn launch_hash(opts: &LaunchOptions) -> u64 {
|
|||||||
opts.proxy_password.hash(&mut h);
|
opts.proxy_password.hash(&mut h);
|
||||||
opts.user_agent.hash(&mut h);
|
opts.user_agent.hash(&mut h);
|
||||||
opts.allow_file_access.hash(&mut h);
|
opts.allow_file_access.hash(&mut h);
|
||||||
|
opts.hide_scrollbars.hash(&mut h);
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,6 +1511,28 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
|||||||
.client
|
.client
|
||||||
.send_command("Page.bringToFront", None, Some(&session_id))
|
.send_command("Page.bringToFront", None, Some(&session_id))
|
||||||
.await;
|
.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)
|
Ok(mgr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,8 +1608,9 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\n\n\
|
||||||
Or start a standalone browser with: agent-browser --launch open <url>\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\
|
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
||||||
Open chrome://inspect/#remote-debugging and toggle it on.",
|
it does NOT expose the standard CDP HTTP API on /json/version. \
|
||||||
|
A full restart with --remote-debugging-port=<port> is required.",
|
||||||
chrome_relaunch_hint(),
|
chrome_relaunch_hint(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1853,11 +1877,24 @@ fn launch_options_from_env() -> LaunchOptions {
|
|||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
|
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
|
||||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
|
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
|
||||||
|
hide_scrollbars: hide_scrollbars_from_env(),
|
||||||
viewport_size: None,
|
viewport_size: None,
|
||||||
use_real_keychain: false,
|
use_real_keychain: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hide_scrollbars_from_env() -> bool {
|
||||||
|
env::var("AGENT_BROWSER_HIDE_SCROLLBARS")
|
||||||
|
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | ""))
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_scrollbars_from_launch_cmd(cmd: &Value) -> bool {
|
||||||
|
cmd.get("hideScrollbars")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or_else(hide_scrollbars_from_env)
|
||||||
|
}
|
||||||
|
|
||||||
async fn try_auto_restore_state(state: &mut DaemonState) {
|
async fn try_auto_restore_state(state: &mut DaemonState) {
|
||||||
let session_name = match state.session_name.as_deref() {
|
let session_name = match state.session_name.as_deref() {
|
||||||
Some(n) if !n.is_empty() => n.to_string(),
|
Some(n) if !n.is_empty() => n.to_string(),
|
||||||
@@ -2021,6 +2058,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
.get("downloadPath")
|
.get("downloadPath")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
|
hide_scrollbars: hide_scrollbars_from_launch_cmd(cmd),
|
||||||
viewport_size: None,
|
viewport_size: None,
|
||||||
use_real_keychain: false,
|
use_real_keychain: false,
|
||||||
};
|
};
|
||||||
@@ -2113,8 +2151,9 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\n\n\
|
||||||
Or start a standalone browser with: agent-browser --launch open <url>\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\
|
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
||||||
Open chrome://inspect/#remote-debugging and toggle it on.",
|
it does NOT expose the standard CDP HTTP API on /json/version. \
|
||||||
|
A full restart with --remote-debugging-port=<port> is required.",
|
||||||
chrome_relaunch_hint(),
|
chrome_relaunch_hint(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -3105,6 +3144,15 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
.get("state")
|
.get("state")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("visible");
|
.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?;
|
wait_for_selector(&mgr.client, &session_id, selector, state_str, timeout_ms).await?;
|
||||||
return Ok(json!({ "waited": "selector", "selector": selector }));
|
return Ok(json!({ "waited": "selector", "selector": selector }));
|
||||||
}
|
}
|
||||||
@@ -3316,6 +3364,49 @@ async fn handle_reload(state: &mut DaemonState) -> Result<Value, String> {
|
|||||||
// Wait helpers
|
// 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(
|
async fn wait_for_selector(
|
||||||
client: &super::cdp::client::CdpClient,
|
client: &super::cdp::client::CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -8570,17 +8661,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_launch_options_from_env_defaults() {
|
fn test_launch_options_from_env_defaults() {
|
||||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.remove("AGENT_BROWSER_HEADED");
|
||||||
|
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||||
let opts = launch_options_from_env();
|
let opts = launch_options_from_env();
|
||||||
assert!(opts.headless);
|
assert!(opts.headless);
|
||||||
assert!(opts.args.is_empty());
|
assert!(opts.args.is_empty());
|
||||||
assert!(!opts.allow_file_access);
|
assert!(!opts.allow_file_access);
|
||||||
|
assert!(opts.hide_scrollbars);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_launch_options_from_env_headed_flag() {
|
fn test_launch_options_from_env_headed_flag() {
|
||||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
_guard.set("AGENT_BROWSER_HEADED", "1");
|
guard.set("AGENT_BROWSER_HEADED", "1");
|
||||||
|
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||||
let opts = launch_options_from_env();
|
let opts = launch_options_from_env();
|
||||||
assert!(
|
assert!(
|
||||||
!opts.headless,
|
!opts.headless,
|
||||||
@@ -8588,6 +8683,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_launch_options_from_env_hide_scrollbars_false() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
|
||||||
|
let opts = launch_options_from_env();
|
||||||
|
assert!(!opts.hide_scrollbars);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_launch_cmd_hide_scrollbars_missing_uses_env_default() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
|
||||||
|
|
||||||
|
assert!(!hide_scrollbars_from_launch_cmd(&json!({
|
||||||
|
"action": "launch"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_launch_cmd_hide_scrollbars_explicit_overrides_env_default() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||||
|
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
|
||||||
|
|
||||||
|
assert!(hide_scrollbars_from_launch_cmd(&json!({
|
||||||
|
"action": "launch",
|
||||||
|
"hideScrollbars": true
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_har_entry_to_json_enriches_request_and_response() {
|
fn test_har_entry_to_json_enriches_request_and_response() {
|
||||||
// wall_time: 2026-03-15T12:00:00Z = 1_773_576_000
|
// wall_time: 2026-03-15T12:00:00Z = 1_773_576_000
|
||||||
|
|||||||
@@ -311,6 +311,42 @@ const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
|||||||
const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10);
|
const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Outcome of a single `Browser.getVersion` liveness probe.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum LivenessProbe {
|
||||||
|
/// Chrome answered — the connection is definitely alive.
|
||||||
|
Responded,
|
||||||
|
/// The CDP transport errored (WebSocket closed/reset) — the socket is gone.
|
||||||
|
TransportError,
|
||||||
|
/// The probe timed out with no response.
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether a CDP connection should be considered alive from one probe.
|
||||||
|
///
|
||||||
|
/// The subtle case is [`LivenessProbe::TimedOut`]. For a browser we launched
|
||||||
|
/// ourselves (`is_external_attach == false`) a hung CDP socket is a real
|
||||||
|
/// problem and the daemon should reconnect. But for an *externally attached*
|
||||||
|
/// browser — the stealth fork's default, where we attach to the user's real
|
||||||
|
/// Chrome — a slow/no response is almost always Chrome being briefly busy or,
|
||||||
|
/// critically, showing the Chrome 136+ "Allow remote debugging?" consent modal,
|
||||||
|
/// which blocks CDP responses until the user clicks Allow.
|
||||||
|
///
|
||||||
|
/// Treating that timeout as "dead" tears down the already-consented connection
|
||||||
|
/// and forces a reconnect, which re-pops the consent prompt; repeated on every
|
||||||
|
/// command it produces an endless prompt loop and a connection storm that can
|
||||||
|
/// freeze Chrome. So for external attaches we keep the connection alive on
|
||||||
|
/// timeout. A genuinely dead external socket instead surfaces as
|
||||||
|
/// [`LivenessProbe::TransportError`] (and Chrome being closed by the user is a
|
||||||
|
/// transport error, not a timeout), so zombie-socket detection is preserved.
|
||||||
|
fn connection_alive_from_probe(probe: LivenessProbe, is_external_attach: bool) -> bool {
|
||||||
|
match probe {
|
||||||
|
LivenessProbe::Responded => true,
|
||||||
|
LivenessProbe::TransportError => false,
|
||||||
|
LivenessProbe::TimedOut => is_external_attach,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BrowserManager {
|
impl BrowserManager {
|
||||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||||
let engine = engine.unwrap_or("chrome");
|
let engine = engine.unwrap_or("chrome");
|
||||||
@@ -829,21 +865,27 @@ impl BrowserManager {
|
|||||||
self.default_timeout_ms
|
self.default_timeout_ms
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the CDP connection is alive by sending a simple command.
|
/// Checks if the CDP connection is alive by sending a `Browser.getVersion`
|
||||||
/// Returns false if the command times out or fails.
|
/// probe. See [`connection_alive_from_probe`] for how the outcome maps to a
|
||||||
|
/// liveness verdict — in particular why a timeout does NOT tear down an
|
||||||
|
/// externally-attached browser.
|
||||||
pub async fn is_connection_alive(&self) -> bool {
|
pub async fn is_connection_alive(&self) -> bool {
|
||||||
let timeout = tokio::time::Duration::from_secs(3);
|
let timeout = tokio::time::Duration::from_secs(3);
|
||||||
let result = tokio::time::timeout(
|
let probe = match tokio::time::timeout(
|
||||||
timeout,
|
timeout,
|
||||||
self.client
|
self.client
|
||||||
.send_command_no_params("Browser.getVersion", None),
|
.send_command_no_params("Browser.getVersion", None),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
match result {
|
Ok(Ok(_)) => LivenessProbe::Responded,
|
||||||
Ok(Ok(_)) => true,
|
Ok(Err(_)) => LivenessProbe::TransportError,
|
||||||
Ok(Err(_)) | Err(_) => false,
|
Err(_) => LivenessProbe::TimedOut,
|
||||||
}
|
};
|
||||||
|
// No child process => we attached to an external browser (the user's
|
||||||
|
// real Chrome — the stealth fork's default).
|
||||||
|
let is_external_attach = self.browser_process.is_none();
|
||||||
|
connection_alive_from_probe(probe, is_external_attach)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-blocking check whether the locally-launched browser process has exited
|
/// Non-blocking check whether the locally-launched browser process has exited
|
||||||
@@ -1728,6 +1770,35 @@ mod tests {
|
|||||||
assert_eq!(format_tab_id(42), "t42");
|
assert_eq!(format_tab_id(42), "t42");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liveness_responded_is_alive_for_both_kinds() {
|
||||||
|
assert!(connection_alive_from_probe(LivenessProbe::Responded, true));
|
||||||
|
assert!(connection_alive_from_probe(LivenessProbe::Responded, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liveness_transport_error_is_dead_for_both_kinds() {
|
||||||
|
// A closed/reset WebSocket is a genuine death — reconnect in both cases.
|
||||||
|
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, true));
|
||||||
|
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liveness_timeout_keeps_external_attach_alive() {
|
||||||
|
// Regression guard for the remote-debugging consent storm: a timed-out
|
||||||
|
// probe must NOT tear down an externally-attached browser, otherwise the
|
||||||
|
// daemon reconnects and re-pops Chrome's "Allow remote debugging?" modal
|
||||||
|
// on every command (endless prompts + browser freeze).
|
||||||
|
assert!(connection_alive_from_probe(LivenessProbe::TimedOut, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liveness_timeout_marks_launched_browser_dead() {
|
||||||
|
// A browser we launched that stops responding is a real problem worth a
|
||||||
|
// reconnect (and has no consent modal to worry about).
|
||||||
|
assert!(!connection_alive_from_probe(LivenessProbe::TimedOut, false));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_tab_ref_id() {
|
fn test_parse_tab_ref_id() {
|
||||||
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
|
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
|
||||||
|
|||||||
+107
-96
@@ -103,6 +103,9 @@ pub struct LaunchOptions {
|
|||||||
pub ignore_https_errors: bool,
|
pub ignore_https_errors: bool,
|
||||||
pub color_scheme: Option<String>,
|
pub color_scheme: Option<String>,
|
||||||
pub download_path: Option<String>,
|
pub download_path: Option<String>,
|
||||||
|
/// Hide native scrollbars in headless Chromium screenshots by launching
|
||||||
|
/// Chrome with `--hide-scrollbars`.
|
||||||
|
pub hide_scrollbars: bool,
|
||||||
/// Initial viewport dimensions used for `--window-size` so the content
|
/// Initial viewport dimensions used for `--window-size` so the content
|
||||||
/// area matches the desired viewport from the start.
|
/// area matches the desired viewport from the start.
|
||||||
pub viewport_size: Option<(u32, u32)>,
|
pub viewport_size: Option<(u32, u32)>,
|
||||||
@@ -130,6 +133,7 @@ impl Default for LaunchOptions {
|
|||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
color_scheme: None,
|
color_scheme: None,
|
||||||
download_path: None,
|
download_path: None,
|
||||||
|
hide_scrollbars: true,
|
||||||
viewport_size: None,
|
viewport_size: None,
|
||||||
use_real_keychain: false,
|
use_real_keychain: false,
|
||||||
}
|
}
|
||||||
@@ -178,6 +182,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
|||||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
// injected in headless mode). Skip --headless when extensions are loaded.
|
||||||
if options.headless && !has_extensions {
|
if options.headless && !has_extensions {
|
||||||
args.push("--headless=new".to_string());
|
args.push("--headless=new".to_string());
|
||||||
|
// Linux paints native scrollbars into viewport screenshots unless
|
||||||
|
// Chrome is launched with this flag. `--hide-scrollbars` is
|
||||||
|
// presence-based, so agent-browser exposes --hide-scrollbars false
|
||||||
|
// as the public opt-out instead of forwarding a fake inverse switch.
|
||||||
|
if options.hide_scrollbars {
|
||||||
|
args.push("--hide-scrollbars".to_string());
|
||||||
|
}
|
||||||
// Enable SwiftShader software rendering in headless mode. This
|
// Enable SwiftShader software rendering in headless mode. This
|
||||||
// prevents silent crashes in environments where GPU drivers are
|
// prevents silent crashes in environments where GPU drivers are
|
||||||
// missing or restricted (VMs, containers, some cloud machines)
|
// missing or restricted (VMs, containers, some cloud machines)
|
||||||
@@ -679,52 +690,52 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
|
|
||||||
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
||||||
///
|
///
|
||||||
/// Tries the exact WebSocket path from DevToolsActivePort first (single
|
/// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only
|
||||||
/// prompt on M144+), then falls back to legacy HTTP discovery for older
|
/// by a consent-free TCP liveness check. Falls back to HTTP discovery on the
|
||||||
/// Chrome versions. This order avoids triggering duplicate remote-debugging
|
/// same port for older Chrome layouts.
|
||||||
/// permission prompts (#1210, #1206).
|
///
|
||||||
|
/// Crucially, this does NOT open a throwaway verification WebSocket. On
|
||||||
|
/// Chrome 136+ the "Allow remote debugging?" consent is granted *per
|
||||||
|
/// connection*: a probe WebSocket we then close would consume the user's one
|
||||||
|
/// Allow click, leaving the real connection (opened afterwards) unconsented —
|
||||||
|
/// which manifests as an endless prompt loop or a hung command. By skipping the
|
||||||
|
/// probe, the real connection is the single WebSocket the user consents to.
|
||||||
|
/// (Background: #1210, #1206 duplicate-prompt reports.)
|
||||||
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
|
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
|
||||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
// Consent-free liveness: a bare TCP connect does not trigger the
|
||||||
if verify_ws_endpoint(&ws_url).await {
|
// remote-debugging consent flow (that fires on the CDP/WebSocket upgrade),
|
||||||
return Ok(ws_url);
|
// so we can tell "Chrome is listening" from "stale DevToolsActivePort"
|
||||||
|
// without burning a prompt.
|
||||||
|
if tcp_port_alive(port).await {
|
||||||
|
return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-M144 fallback: HTTP endpoints (/json/version, /json/list, etc.)
|
// Port isn't accepting connections (stale file / different layout). Fall
|
||||||
|
// back to HTTP discovery for older Chrome before giving up.
|
||||||
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
||||||
return Ok(ws_url);
|
return Ok(ws_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"Cannot connect to Chrome on port {}: both direct WebSocket and HTTP discovery failed",
|
"Cannot connect to Chrome on port {}: port not reachable and HTTP discovery failed",
|
||||||
port
|
port
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that a WebSocket endpoint is a live CDP server by sending
|
/// Consent-free check that something is accepting TCP connections on
|
||||||
/// `Browser.getVersion` and checking for a valid response.
|
/// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not
|
||||||
async fn verify_ws_endpoint(ws_url: &str) -> bool {
|
/// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to
|
||||||
use futures_util::{SinkExt, StreamExt};
|
/// use for liveness before handing the URL to the single real connection.
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
async fn tcp_port_alive(port: u16) -> bool {
|
||||||
|
let timeout = Duration::from_secs(1);
|
||||||
let timeout = Duration::from_secs(2);
|
matches!(
|
||||||
let result = tokio::time::timeout(timeout, async {
|
tokio::time::timeout(
|
||||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?;
|
timeout,
|
||||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||||
ws.send(Message::Text(cmd.into())).await.ok()?;
|
)
|
||||||
while let Some(Ok(msg)) = ws.next().await {
|
.await,
|
||||||
if let Message::Text(text) = msg {
|
Ok(Ok(_))
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
)
|
||||||
if v.get("id").and_then(|id| id.as_u64()) == Some(1) {
|
|
||||||
let _ = ws.close(None).await;
|
|
||||||
return Some(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
matches!(result, Ok(Some(())))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default Chrome user-data directory paths for the current platform.
|
/// Returns the default Chrome user-data directory paths for the current platform.
|
||||||
@@ -1360,6 +1371,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let result = build_chrome_args(&opts).unwrap();
|
let result = build_chrome_args(&opts).unwrap();
|
||||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||||
|
assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
|
||||||
assert!(result
|
assert!(result
|
||||||
.args
|
.args
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1380,6 +1392,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let result = build_chrome_args(&opts).unwrap();
|
let result = build_chrome_args(&opts).unwrap();
|
||||||
assert!(!result.args.iter().any(|a| a.contains("--headless")));
|
assert!(!result.args.iter().any(|a| a.contains("--headless")));
|
||||||
|
assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
|
||||||
assert!(!result
|
assert!(!result
|
||||||
.args
|
.args
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1434,6 +1447,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
|
||||||
|
let opts = LaunchOptions {
|
||||||
|
headless: true,
|
||||||
|
hide_scrollbars: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = build_chrome_args(&opts).unwrap();
|
||||||
|
assert!(
|
||||||
|
!result.args.iter().any(|a| a == "--hide-scrollbars"),
|
||||||
|
"--hide-scrollbars false should suppress agent-browser's default hide switch"
|
||||||
|
);
|
||||||
|
if let Some(ref dir) = result.temp_user_data_dir {
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_args_start_maximized_suppresses_default_window_size() {
|
fn test_build_args_start_maximized_suppresses_default_window_size() {
|
||||||
let opts = LaunchOptions {
|
let opts = LaunchOptions {
|
||||||
@@ -1474,6 +1504,10 @@ mod tests {
|
|||||||
!result.args.iter().any(|a| a.contains("--headless")),
|
!result.args.iter().any(|a| a.contains("--headless")),
|
||||||
"headless flag should be omitted when extensions are present"
|
"headless flag should be omitted when extensions are present"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
!result.args.iter().any(|a| a == "--hide-scrollbars"),
|
||||||
|
"scrollbars should remain visible when extensions force headed mode"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!result.args.iter().any(|a| a.contains("--window-size")),
|
!result.args.iter().any(|a| a.contains("--window-size")),
|
||||||
"window-size should be omitted when extensions force headed mode"
|
"window-size should be omitted when extensions force headed mode"
|
||||||
@@ -1870,83 +1904,60 @@ mod tests {
|
|||||||
// auto_connect_cdp discovery-order tests (#1210, #1206)
|
// auto_connect_cdp discovery-order tests (#1210, #1206)
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
/// When DevToolsActivePort provides a ws_path and the port is reachable,
|
/// When the port is live, `resolve_cdp_from_active_port` returns the exact
|
||||||
/// `resolve_cdp_from_active_port` should return the exact ws_path URL
|
/// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT
|
||||||
/// WITHOUT calling HTTP discovery first.
|
/// probe with a verification WebSocket (which would burn Chrome 136+'s
|
||||||
|
/// per-connection remote-debugging consent on a throwaway socket).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_cdp_from_active_port_prefers_ws_path() {
|
async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() {
|
||||||
use futures_util::{SinkExt, StreamExt};
|
// A bound listener makes the port TCP-reachable. We do NOT accept/serve
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
// any WebSocket — resolve must succeed from the bare TCP check alone.
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
let ws_path = "/devtools/browser/test-uuid-1234".to_string();
|
let ws_path = "/devtools/browser/test-uuid-1234";
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
let result = resolve_cdp_from_active_port(port, ws_path).await;
|
||||||
// accept: verify_ws_endpoint() WebSocket handshake
|
assert!(result.is_ok(), "should succeed when port is live: {:?}", result);
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
assert_eq!(
|
||||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
result.unwrap(),
|
||||||
if let Some(Ok(WsMsg::Text(text))) = ws.next().await {
|
format!("ws://127.0.0.1:{}{}", port, ws_path),
|
||||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
"should return the exact DevToolsActivePort URL untouched"
|
||||||
let id = req.get("id").unwrap();
|
|
||||||
let reply = format!(
|
|
||||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/147"}}}}"#,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
ws.send(WsMsg::Text(reply)).await.unwrap();
|
|
||||||
}
|
|
||||||
let _ = ws.close(None).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = resolve_cdp_from_active_port(port, &ws_path).await;
|
|
||||||
assert!(result.is_ok(), "should succeed: {:?}", result);
|
|
||||||
let url = result.unwrap();
|
|
||||||
assert!(
|
|
||||||
url.contains("test-uuid-1234"),
|
|
||||||
"should use exact ws_path from DevToolsActivePort, got: {}",
|
|
||||||
url
|
|
||||||
);
|
);
|
||||||
assert_eq!(url, format!("ws://127.0.0.1:{}{}", port, ws_path));
|
drop(listener);
|
||||||
server.await.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the exact ws_path connection fails, `resolve_cdp_from_active_port`
|
/// Regression guard for the consent storm: resolving the URL must only do a
|
||||||
/// should fall back to HTTP discovery.
|
/// bare TCP connect, never a WebSocket/CDP handshake. On Chrome 136+ a
|
||||||
|
/// handshake on a throwaway socket consumes the user's one "Allow remote
|
||||||
|
/// debugging?" click, leaving the real connection unconsented (endless
|
||||||
|
/// prompts / hang).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_cdp_from_active_port_falls_back_to_http_discovery() {
|
async fn test_resolve_cdp_from_active_port_does_not_open_websocket() {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
// 1st accept: verify_ws_endpoint() ws_path probe — reject (just close)
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
let (s1, _) = listener.accept().await.unwrap();
|
// The liveness check connects then drops without writing anything.
|
||||||
drop(s1);
|
// Assert we receive no WebSocket upgrade bytes (EOF / no data).
|
||||||
|
let mut buf = [0u8; 128];
|
||||||
// 2nd accept: HTTP /json/version from discover_cdp_url()
|
let read = tokio::time::timeout(
|
||||||
let (mut s2, _) = listener.accept().await.unwrap();
|
Duration::from_millis(500),
|
||||||
let mut buf = [0u8; 2048];
|
stream.read(&mut buf),
|
||||||
let _ = s2.read(&mut buf).await;
|
)
|
||||||
let body = format!(
|
.await;
|
||||||
r#"{{"webSocketDebuggerUrl":"ws://127.0.0.1:{}/devtools/browser/fallback-uuid"}}"#,
|
match read {
|
||||||
port
|
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
|
||||||
);
|
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
|
||||||
let resp = format!(
|
}
|
||||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
|
|
||||||
body.len(),
|
|
||||||
body
|
|
||||||
);
|
|
||||||
s2.write_all(resp.as_bytes()).await.unwrap();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = resolve_cdp_from_active_port(port, "/devtools/browser/nonexistent-uuid").await;
|
let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await;
|
||||||
assert!(result.is_ok(), "should fall back to HTTP: {:?}", result);
|
assert_eq!(
|
||||||
let url = result.unwrap();
|
result.unwrap(),
|
||||||
assert!(
|
format!("ws://127.0.0.1:{}/devtools/browser/abc", port)
|
||||||
url.contains("fallback-uuid"),
|
|
||||||
"should use HTTP discovery fallback, got: {}",
|
|
||||||
url
|
|
||||||
);
|
);
|
||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,61 @@ async fn create_storage_state_with_cookie(path: &str, cookie_name: &str, cookie_
|
|||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_raw_http_request(port: u64, request: &str) -> String {
|
||||||
|
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
|
||||||
|
.await
|
||||||
|
.expect("HTTP client should connect to stream server");
|
||||||
|
stream
|
||||||
|
.write_all(request.as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("HTTP request should be written");
|
||||||
|
stream
|
||||||
|
.shutdown()
|
||||||
|
.await
|
||||||
|
.expect("HTTP client write side should shut down");
|
||||||
|
|
||||||
|
let mut response = Vec::new();
|
||||||
|
stream
|
||||||
|
.read_to_end(&mut response)
|
||||||
|
.await
|
||||||
|
.expect("HTTP response should be read");
|
||||||
|
String::from_utf8(response).expect("HTTP response should be utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn spawn_fake_daemon_socket(
|
||||||
|
socket_dir: &std::path::Path,
|
||||||
|
session_name: &str,
|
||||||
|
) -> tokio::sync::oneshot::Receiver<String> {
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
|
||||||
|
let socket_path = socket_dir.join(format!("{session_name}.sock"));
|
||||||
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
|
let listener =
|
||||||
|
tokio::net::UnixListener::bind(&socket_path).expect("fake daemon socket should bind");
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut reader = tokio::io::BufReader::new(stream);
|
||||||
|
let mut command = String::new();
|
||||||
|
if reader.read_line(&mut command).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = reader.into_inner();
|
||||||
|
let _ = stream
|
||||||
|
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
|
||||||
|
.await;
|
||||||
|
let _ = stream.write_all(b"\n").await;
|
||||||
|
let _ = tx.send(command);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core: launch, navigate, evaluate, url, title, close
|
// Core: launch, navigate, evaluate, url, title, close
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -363,6 +418,98 @@ async fn e2e_runtime_stream_enable_before_launch_attaches_and_disables() {
|
|||||||
let _ = std::fs::remove_dir_all(&socket_dir);
|
let _ = std::fs::remove_dir_all(&socket_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn e2e_stream_command_requires_same_origin_before_daemon_relay() {
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
|
||||||
|
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("target")
|
||||||
|
.join("t");
|
||||||
|
std::fs::create_dir_all(&temp_parent).expect("socket temp parent should be created");
|
||||||
|
let socket_dir = tempfile::Builder::new()
|
||||||
|
.prefix("ab-e2e-")
|
||||||
|
.tempdir_in(temp_parent)
|
||||||
|
.expect("socket dir should be created");
|
||||||
|
guard.set(
|
||||||
|
"AGENT_BROWSER_SOCKET_DIR",
|
||||||
|
socket_dir
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.expect("socket dir should be utf-8"),
|
||||||
|
);
|
||||||
|
guard.set("AGENT_BROWSER_SESSION", "x");
|
||||||
|
|
||||||
|
let mut state = DaemonState::new();
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "1", "action": "stream_enable", "port": 0 }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
let port = get_data(&resp)["port"]
|
||||||
|
.as_u64()
|
||||||
|
.expect("stream enable should report the bound port");
|
||||||
|
|
||||||
|
let mut daemon_command = spawn_fake_daemon_socket(socket_dir.path(), "x").await;
|
||||||
|
let body = r#"{"action":"tabs"}"#;
|
||||||
|
let cross_origin_request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_raw_http_request(port, &cross_origin_request).await;
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||||
|
"unexpected cross-origin response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"forbidden command response exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(100), &mut daemon_command)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"cross-origin command request reached daemon relay"
|
||||||
|
);
|
||||||
|
|
||||||
|
let same_origin_request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
let response = send_raw_http_request(port, &same_origin_request).await;
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 200 OK"),
|
||||||
|
"unexpected same-origin response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response.contains(&format!(
|
||||||
|
"Access-Control-Allow-Origin: http://localhost:{port}"
|
||||||
|
)),
|
||||||
|
"same-origin command response did not reflect origin: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"same-origin command response exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
|
||||||
|
.await
|
||||||
|
.expect("same-origin request should reach fake daemon")
|
||||||
|
.expect("fake daemon should return relayed command");
|
||||||
|
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "2", "action": "stream_disable" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Snapshot with refs and ref-based click
|
// Snapshot with refs and ref-based click
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -163,6 +163,30 @@ pub async fn resolve_element_center(
|
|||||||
|
|
||||||
// Try cached backend_node_id first (fast path)
|
// Try cached backend_node_id first (fast path)
|
||||||
if let Some(backend_node_id) = entry.backend_node_id {
|
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
|
let result: Result<DomGetBoxModelResult, String> = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.getBoxModel",
|
"DOM.getBoxModel",
|
||||||
@@ -177,6 +201,31 @@ pub async fn resolve_element_center(
|
|||||||
|
|
||||||
if let Ok(r) = result {
|
if let Ok(r) = result {
|
||||||
let (x, y) = box_model_center(&r.model);
|
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()));
|
return Ok((x, y, effective_session_id.to_string()));
|
||||||
}
|
}
|
||||||
// backend_node_id is stale; re-query the accessibility tree below
|
// 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)
|
// Try cached backend_node_id first (fast path)
|
||||||
if let Some(backend_node_id) = entry.backend_node_id {
|
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
|
let result: Result<DomResolveNodeResult, String> = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.resolveNode",
|
"DOM.resolveNode",
|
||||||
@@ -333,6 +400,233 @@ fn resolve_frame_session<'a>(
|
|||||||
.unwrap_or(session_id)
|
.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,
|
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
||||||
/// returning its fresh backendDOMNodeId. This uses the same data source
|
/// returning its fresh backendDOMNodeId. This uses the same data source
|
||||||
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
||||||
|
|||||||
@@ -884,6 +884,46 @@ pub async fn tap_touch(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After a click is dispatched, give the page two animation frames + a
|
||||||
|
/// microtask boundary to let React/Vue/Svelte commit any state update
|
||||||
|
/// scheduled by the click handler. Without this wait, follow-up commands
|
||||||
|
/// (e.g. `inserttext` against the textbox the click was supposed to mount)
|
||||||
|
/// race the renderer and can land on stale or wrong elements.
|
||||||
|
///
|
||||||
|
/// The wait is bounded to ~33ms in the common case (two RAFs at 60fps) and
|
||||||
|
/// returns immediately on any error — never an exception path.
|
||||||
|
///
|
||||||
|
/// Set `AGENT_BROWSER_CLICK_WAIT_STABLE=0` to disable for perf-sensitive
|
||||||
|
/// scripts that don't drive SPA UIs.
|
||||||
|
async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||||
|
if std::env::var("AGENT_BROWSER_CLICK_WAIT_STABLE").as_deref() == Ok("0") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let script = "new Promise(resolve => \
|
||||||
|
requestAnimationFrame(() => \
|
||||||
|
requestAnimationFrame(() => \
|
||||||
|
queueMicrotask(() => resolve(true)))))";
|
||||||
|
// Tight 500ms timeout. RAF normally fires at 16ms, two RAFs total ~33ms.
|
||||||
|
// If the tab is hidden / throttled / page is doing something pathological
|
||||||
|
// and RAF doesn't fire in 500ms, we'd rather return now than stall the
|
||||||
|
// user's click. Without this cap, a stuck RAF inherited the default 30s
|
||||||
|
// CDP timeout and was the main contributor to the "click hangs 5+ min"
|
||||||
|
// user report.
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
client.send_command_typed::<_, Value>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
&EvaluateParams {
|
||||||
|
expression: script.to_string(),
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(true),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn dispatch_click(
|
async fn dispatch_click(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -955,6 +995,7 @@ async fn dispatch_click(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
wait_for_paint_settled(client, session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,141 @@ pub(super) fn cors_headers_for_origin(origin: Option<&str>) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_headers(request: &str) -> &str {
|
||||||
|
request
|
||||||
|
.find("\r\n\r\n")
|
||||||
|
.or_else(|| request.find("\n\n"))
|
||||||
|
.map(|header_end| &request[..header_end])
|
||||||
|
.unwrap_or(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
request_headers(request).lines().find_map(|line| {
|
||||||
|
let (header_name, value) = line.split_once(':')?;
|
||||||
|
if header_name.trim().eq_ignore_ascii_case(name) {
|
||||||
|
Some(value.trim())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_origin(peeked: &[u8]) -> Option<String> {
|
fn parse_origin(peeked: &[u8]) -> Option<String> {
|
||||||
let header_str = std::str::from_utf8(peeked).ok()?;
|
let header_str = std::str::from_utf8(peeked).ok()?;
|
||||||
for line in header_str.lines() {
|
request_header_value(header_str, "origin").map(ToString::to_string)
|
||||||
if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") {
|
}
|
||||||
return Some(line[8..].trim().to_string());
|
|
||||||
|
fn normalize_origin_authority(origin: &str) -> Option<String> {
|
||||||
|
let url = url::Url::parse(origin).ok()?;
|
||||||
|
let host = url.host_str()?.to_ascii_lowercase();
|
||||||
|
let host = if host.contains(':') {
|
||||||
|
format!("[{host}]")
|
||||||
|
} else {
|
||||||
|
host
|
||||||
|
};
|
||||||
|
let default_port = (url.scheme() == "http" && url.port() == Some(80))
|
||||||
|
|| (url.scheme() == "https" && url.port() == Some(443));
|
||||||
|
Some(match url.port() {
|
||||||
|
Some(port) if !default_port => format!("{host}:{port}"),
|
||||||
|
_ => host,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_host_authority(host: &str) -> String {
|
||||||
|
let host = host.trim().to_ascii_lowercase();
|
||||||
|
|
||||||
|
if let Some(bracket_end) = host.rfind(']') {
|
||||||
|
if bracket_end == host.len() - 1 {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
|
||||||
|
let port = &host[bracket_end + 2..];
|
||||||
|
if port == "80" || port == "443" {
|
||||||
|
return host[..=bracket_end].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((name, port)) = host.rsplit_once(':') {
|
||||||
|
if !name.contains(':') && (port == "80" || port == "443") {
|
||||||
|
return name.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
|
||||||
|
host
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authority_host(authority: &str) -> &str {
|
||||||
|
if let Some(stripped) = authority.strip_prefix('[') {
|
||||||
|
if let Some(bracket_end) = stripped.find(']') {
|
||||||
|
return &authority[..=bracket_end + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((host, _port)) = authority.rsplit_once(':') {
|
||||||
|
if !host.contains(':') {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authority
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_loopback_authority(authority: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
authority_host(authority),
|
||||||
|
"localhost" | "127.0.0.1" | "::1" | "[::1]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_authority_matches_host(request: &str, header_name: &str) -> bool {
|
||||||
|
let Some(authority) =
|
||||||
|
request_header_value(request, header_name).and_then(normalize_origin_authority)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(host) = request_header_value(request, "host").map(normalize_host_authority) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
authority == host && is_loopback_authority(&authority) && is_loopback_authority(&host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protects the command relay by requiring same-origin browser metadata.
|
||||||
|
fn is_same_origin_command_request(request: &str) -> bool {
|
||||||
|
if request_header_value(request, "origin").is_some() {
|
||||||
|
header_authority_matches_host(request, "origin")
|
||||||
|
} else {
|
||||||
|
header_authority_matches_host(request, "referer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_cors_headers(request: &str) -> String {
|
||||||
|
match request_header_value(request, "origin") {
|
||||||
|
Some(origin) if is_same_origin_command_request(request) => format!(
|
||||||
|
"Access-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nVary: Origin\r\n"
|
||||||
|
),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_json_error_response_no_cors(
|
||||||
|
stream: &mut tokio::net::TcpStream,
|
||||||
|
status: &str,
|
||||||
|
error: &str,
|
||||||
|
) {
|
||||||
|
let body = format!(
|
||||||
|
r#"{{"success":false,"error":{}}}"#,
|
||||||
|
serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error))
|
||||||
|
);
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.write_all(body.as_bytes()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_http_request(
|
pub(super) async fn handle_http_request(
|
||||||
@@ -61,6 +188,25 @@ pub(super) async fn handle_http_request(
|
|||||||
let origin = parse_origin(peeked);
|
let origin = parse_origin(peeked);
|
||||||
|
|
||||||
if method == "OPTIONS" {
|
if method == "OPTIONS" {
|
||||||
|
if path == "/api/command" {
|
||||||
|
if !is_same_origin_command_request(&request) {
|
||||||
|
write_json_error_response_no_cors(
|
||||||
|
&mut stream,
|
||||||
|
"403 Forbidden",
|
||||||
|
"Origin or Referer does not match Host header.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cors_headers = command_cors_headers(&request);
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 204 No Content\r\n{cors_headers}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
"HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
);
|
);
|
||||||
@@ -69,13 +215,28 @@ pub(super) async fn handle_http_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if method == "POST" {
|
if method == "POST" {
|
||||||
|
if path == "/api/command" && !is_same_origin_command_request(&request) {
|
||||||
|
write_json_error_response_no_cors(
|
||||||
|
&mut stream,
|
||||||
|
"403 Forbidden",
|
||||||
|
"Origin or Referer does not match Host header.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let full_body = read_full_body(&mut stream, peeked).await;
|
let full_body = read_full_body(&mut stream, peeked).await;
|
||||||
if full_body.is_none()
|
if full_body.is_none()
|
||||||
&& (path == "/api/chat" || path == "/api/sessions" || path == "/api/command")
|
&& (path == "/api/chat" || path == "/api/sessions" || path == "/api/command")
|
||||||
{
|
{
|
||||||
let body = r#"{"error":"Request body too large"}"#;
|
let body = r#"{"error":"Request body too large"}"#;
|
||||||
|
let cors_headers = if path == "/api/command" {
|
||||||
|
command_cors_headers(&request)
|
||||||
|
} else {
|
||||||
|
CORS_HEADERS.to_string()
|
||||||
|
};
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||||
body.len()
|
body.len()
|
||||||
);
|
);
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
@@ -117,8 +278,9 @@ pub(super) async fn handle_http_request(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
let cors_headers = command_cors_headers(&request);
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||||
resp_body.len()
|
resp_body.len()
|
||||||
);
|
);
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
@@ -313,3 +475,241 @@ pub(super) fn serve_embedded_file(url_path: &str) -> (&'static str, &'static str
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_utils::EnvGuard;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
async fn send_request_to_handler(request: &str, session_name: &str) -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let peeked = request.as_bytes().to_vec();
|
||||||
|
let last_tabs = Arc::new(RwLock::new(Vec::new()));
|
||||||
|
let last_engine = Arc::new(RwLock::new("chrome".to_string()));
|
||||||
|
let session_name = session_name.to_string();
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
handle_http_request(stream, &peeked, &last_tabs, &last_engine, &session_name).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||||
|
client.write_all(request.as_bytes()).await.unwrap();
|
||||||
|
client.shutdown().await.unwrap();
|
||||||
|
|
||||||
|
let mut response = Vec::new();
|
||||||
|
client.read_to_end(&mut response).await.unwrap();
|
||||||
|
server.await.unwrap();
|
||||||
|
|
||||||
|
String::from_utf8(response).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn spawn_fake_daemon(
|
||||||
|
socket_dir: &std::path::Path,
|
||||||
|
session_name: &str,
|
||||||
|
) -> oneshot::Receiver<String> {
|
||||||
|
let socket_path = socket_dir.join(format!("{session_name}.sock"));
|
||||||
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
|
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut reader = tokio::io::BufReader::new(stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await.unwrap();
|
||||||
|
|
||||||
|
let mut stream = reader.into_inner();
|
||||||
|
stream
|
||||||
|
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
stream.write_all(b"\n").await.unwrap();
|
||||||
|
let _ = tx.send(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn cross_origin_command_post_is_rejected_without_relaying_to_daemon() {
|
||||||
|
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("target")
|
||||||
|
.join("t");
|
||||||
|
std::fs::create_dir_all(&temp_parent).unwrap();
|
||||||
|
let socket_dir = tempfile::Builder::new()
|
||||||
|
.prefix("ab-")
|
||||||
|
.tempdir_in(temp_parent)
|
||||||
|
.unwrap();
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||||
|
guard.set(
|
||||||
|
"AGENT_BROWSER_SOCKET_DIR",
|
||||||
|
socket_dir.path().to_str().unwrap(),
|
||||||
|
);
|
||||||
|
guard.remove("XDG_RUNTIME_DIR");
|
||||||
|
|
||||||
|
let session_name = "x";
|
||||||
|
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
|
||||||
|
let body = r#"{"action":"tabs"}"#;
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_request_to_handler(&request, session_name).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(50), daemon_command)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"cross-origin request reached daemon command relay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn cross_origin_command_preflight_is_rejected_without_wildcard_cors() {
|
||||||
|
let request = concat!(
|
||||||
|
"OPTIONS /api/command HTTP/1.1\r\n",
|
||||||
|
"Host: localhost:7777\r\n",
|
||||||
|
"Origin: https://evil.example\r\n",
|
||||||
|
"Access-Control-Request-Method: POST\r\n",
|
||||||
|
"Access-Control-Request-Headers: content-type\r\n",
|
||||||
|
"\r\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_request_to_handler(request, "x").await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"forbidden command preflight exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn command_post_without_origin_or_referer_is_rejected() {
|
||||||
|
let body = r#"{"action":"tabs"}"#;
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_request_to_handler(&request, "x").await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"forbidden command response exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn command_post_with_dns_rebinding_host_is_rejected() {
|
||||||
|
let body = r#"{"action":"tabs"}"#;
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: attacker.example:7777\r\nOrigin: http://attacker.example:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_request_to_handler(&request, "x").await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"forbidden command response exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn command_post_ignores_header_like_body_lines() {
|
||||||
|
let body = "Referer: http://localhost:7777\r\n{\"action\":\"tabs\"}";
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_request_to_handler(&request, "x").await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"forbidden command response exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn same_origin_command_post_relays_without_wildcard_cors() {
|
||||||
|
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("target")
|
||||||
|
.join("t");
|
||||||
|
std::fs::create_dir_all(&temp_parent).unwrap();
|
||||||
|
let socket_dir = tempfile::Builder::new()
|
||||||
|
.prefix("ab-")
|
||||||
|
.tempdir_in(temp_parent)
|
||||||
|
.unwrap();
|
||||||
|
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||||
|
guard.set(
|
||||||
|
"AGENT_BROWSER_SOCKET_DIR",
|
||||||
|
socket_dir.path().to_str().unwrap(),
|
||||||
|
);
|
||||||
|
guard.remove("XDG_RUNTIME_DIR");
|
||||||
|
|
||||||
|
let session_name = "x";
|
||||||
|
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
|
||||||
|
let body = r#"{"action":"tabs"}"#;
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: http://localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = send_request_to_handler(&request, session_name).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.starts_with("HTTP/1.1 200 OK"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response.contains("Access-Control-Allow-Origin: http://localhost:7777"),
|
||||||
|
"same-origin command response did not reflect origin: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("Access-Control-Allow-Origin: *"),
|
||||||
|
"same-origin command response exposed wildcard CORS: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -1583,6 +1583,8 @@ Usage: agent-browser screenshot [selector] [path]
|
|||||||
|
|
||||||
Captures a screenshot of the current page. If no path is provided,
|
Captures a screenshot of the current page. If no path is provided,
|
||||||
saves to a temporary directory with a generated filename.
|
saves to a temporary directory with a generated filename.
|
||||||
|
Headless Chromium screenshots hide native scrollbars for consistent image output.
|
||||||
|
Pass --hide-scrollbars false when launching to keep native scrollbars visible.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--full, -f Capture full page (not just viewport)
|
--full, -f Capture full page (not just viewport)
|
||||||
@@ -3098,6 +3100,8 @@ Options:
|
|||||||
e.g., --proxy-bypass "localhost,*.internal.com"
|
e.g., --proxy-bypass "localhost,*.internal.com"
|
||||||
--ignore-https-errors Ignore HTTPS certificate errors
|
--ignore-https-errors Ignore HTTPS certificate errors
|
||||||
--allow-file-access Allow file:// URLs to access local files (Chromium only)
|
--allow-file-access Allow file:// URLs to access local files (Chromium only)
|
||||||
|
--hide-scrollbars <bool> Hide native scrollbars in headless Chromium screenshots (default: true)
|
||||||
|
Use --hide-scrollbars false to keep scrollbars visible
|
||||||
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
|
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
|
||||||
--device <name> iOS device name (e.g., "iPhone 15 Pro")
|
--device <name> iOS device name (e.g., "iPhone 15 Pro")
|
||||||
--json JSON output
|
--json JSON output
|
||||||
@@ -3137,11 +3141,12 @@ Configuration:
|
|||||||
Boolean flags accept an optional true/false value to override config:
|
Boolean flags accept an optional true/false value to override config:
|
||||||
--headed (same as --headed true)
|
--headed (same as --headed true)
|
||||||
--headed false (disables "headed": true from config)
|
--headed false (disables "headed": true from config)
|
||||||
|
--hide-scrollbars false (keeps native scrollbars visible in headless Chromium screenshots)
|
||||||
|
|
||||||
Extensions from user and project configs are merged (not replaced).
|
Extensions from user and project configs are merged (not replaced).
|
||||||
|
|
||||||
Example agent-browser.json:
|
Example agent-browser.json:
|
||||||
{{"headed": true, "proxy": "http://localhost:8080", "profile": "./browser-data"}}
|
{{"headed": true, "hideScrollbars": false, "proxy": "http://localhost:8080"}}
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||||
@@ -3161,6 +3166,7 @@ Environment:
|
|||||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, browserless, agentcore)
|
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, browserless, agentcore)
|
||||||
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
||||||
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
|
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
|
||||||
|
AGENT_BROWSER_HIDE_SCROLLBARS Hide scrollbars in headless Chromium screenshots (default: true)
|
||||||
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
||||||
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
||||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
|
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ services:
|
|||||||
|
|
||||||
# Build both targets in parallel
|
# Build both targets in parallel
|
||||||
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
|
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
|
||||||
PID1=$!
|
PID1=$$!
|
||||||
|
|
||||||
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
|
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
|
||||||
PID2=$!
|
PID2=$$!
|
||||||
|
|
||||||
# Wait for both to complete
|
# Wait for both and check exit codes individually — without this
|
||||||
wait $PID1 $PID2
|
# 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 ""
|
||||||
echo "✓ Linux platforms built successfully!"
|
echo "✓ Linux platforms built successfully!"
|
||||||
@@ -65,10 +71,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
|
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
|
||||||
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
|
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
|
||||||
|
# NOTE: $$ escapes a literal $ for the in-container shell. A single $ is
|
||||||
|
# interpolated by docker compose at YAML parse time against the *host*
|
||||||
|
# environment, which silently drops script-local variables like SRC
|
||||||
|
# (caused 0.27.0-fork.7 to ship with a stale linux-arm64 binary because
|
||||||
|
# the cp command resolved to `cp "" "/output/"` after compose ate $SRC
|
||||||
|
# and $OUTPUT_NAME). $TARGET / $OUTPUT_NAME are set via `environment:`
|
||||||
|
# below — those are also passed into the container, so $$TARGET and
|
||||||
|
# $$OUTPUT_NAME read them at script time.
|
||||||
command: |
|
command: |
|
||||||
-c '
|
-c '
|
||||||
cargo zigbuild --release --target $TARGET
|
set -e
|
||||||
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
|
cargo zigbuild --release --target $$TARGET
|
||||||
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
|
SRC="/build/target/$$TARGET/release/agent-browser"
|
||||||
echo "✓ Built $OUTPUT_NAME"
|
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",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.2",
|
"version": "0.27.0-fork.9",
|
||||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.1.3",
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
"bin",
|
||||||
"scripts",
|
"scripts",
|
||||||
@@ -21,9 +22,9 @@
|
|||||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||||
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
"build:macos": "npm run version:sync && bash -c 'cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & PID1=$!; cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1' && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
"build:all-platforms": "npm run version:sync && npm run build:linux && npm run build:windows && npm run build:macos",
|
||||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||||
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
|
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
|
||||||
"postinstall": "node scripts/postinstall.js"
|
"postinstall": "node scripts/postinstall.js"
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
packages:
|
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
|
agent-browser screenshot --annotate map.png # numbered labels + legend keyed to snapshot refs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Headless Chromium screenshots hide native scrollbars for consistent image output.
|
||||||
|
Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
|
||||||
|
|
||||||
`--annotate` is designed for multimodal models: each label `[N]` maps to ref `@eN`.
|
`--annotate` is designed for multimodal models: each label `[N]` maps to ref `@eN`.
|
||||||
|
|
||||||
### Handle multiple pages via tabs
|
### Handle multiple pages via tabs
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ agent-browser screenshot --full # Full page
|
|||||||
agent-browser pdf output.pdf # Save as PDF
|
agent-browser pdf output.pdf # Save as PDF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Headless Chromium screenshots hide native scrollbars for consistent image output.
|
||||||
|
Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
|
||||||
|
|
||||||
## Video Recording
|
## Video Recording
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -309,6 +312,7 @@ agent-browser --headers <json> ... # HTTP headers scoped to URL's origin
|
|||||||
agent-browser --executable-path <p> # Custom browser executable
|
agent-browser --executable-path <p> # Custom browser executable
|
||||||
agent-browser --extension <path> ... # Load browser extension (repeatable)
|
agent-browser --extension <path> ... # Load browser extension (repeatable)
|
||||||
agent-browser --ignore-https-errors # Ignore SSL certificate errors
|
agent-browser --ignore-https-errors # Ignore SSL certificate errors
|
||||||
|
agent-browser --hide-scrollbars false # Keep native scrollbars visible in headless Chromium screenshots
|
||||||
agent-browser --help # Show help (-h)
|
agent-browser --help # Show help (-h)
|
||||||
agent-browser --version # Show version (-V)
|
agent-browser --version # Show version (-V)
|
||||||
agent-browser <command> --help # Show detailed help for a command
|
agent-browser <command> --help # Show detailed help for a command
|
||||||
@@ -383,6 +387,7 @@ AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
|
|||||||
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
|
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
|
||||||
AGENT_BROWSER_INIT_SCRIPTS="/a.js,/b.js" # Comma-separated init script paths
|
AGENT_BROWSER_INIT_SCRIPTS="/a.js,/b.js" # Comma-separated init script paths
|
||||||
AGENT_BROWSER_ENABLE="react-devtools" # Comma-separated built-in init script features
|
AGENT_BROWSER_ENABLE="react-devtools" # Comma-separated built-in init script features
|
||||||
|
AGENT_BROWSER_HIDE_SCROLLBARS="false" # Keep native scrollbars visible in headless Chromium screenshots
|
||||||
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
|
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
|
||||||
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
|
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
|
||||||
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
|
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
|
||||||
|
|||||||
Reference in New Issue
Block a user