use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::Duration; use super::discovery::discover_cdp_url; pub struct ChromeProcess { child: Child, pub ws_url: String, temp_user_data_dir: Option, /// On Unix, the process group ID used to kill the entire Chrome process tree. #[cfg(unix)] pgid: Option, } impl ChromeProcess { pub fn kill(&mut self) { let _ = self.child.kill(); // On Unix, kill the entire process group to ensure Chrome helper // processes (GPU, renderer, utility, crashpad) are also terminated. // This prevents orphaned Chrome processes from blocking the user's // normal Chrome (issue #1113). #[cfg(unix)] if let Some(pgid) = self.pgid { unsafe { libc::kill(-pgid, libc::SIGKILL); } } let _ = self.child.wait(); } /// Returns the OS process ID of the Chrome child process. pub fn id(&self) -> u32 { self.child.id() } /// Non-blocking check whether Chrome has exited. /// Returns `true` if the process has exited (and reaps it), `false` if still running. pub fn has_exited(&mut self) -> bool { matches!(self.child.try_wait(), Ok(Some(_)) | Err(_)) } /// Wait for Chrome to exit on its own (after Browser.close CDP command), /// falling back to kill() if it doesn't exit within the timeout. /// This allows Chrome to flush cookies and other state to the user-data-dir. pub fn wait_or_kill(&mut self, timeout: Duration) { let start = std::time::Instant::now(); let poll_interval = Duration::from_millis(50); while start.elapsed() < timeout { match self.child.try_wait() { Ok(Some(_)) => return, Ok(None) => std::thread::sleep(poll_interval), Err(_) => break, } } self.kill(); } } impl Drop for ChromeProcess { fn drop(&mut self) { self.kill(); if let Some(ref dir) = self.temp_user_data_dir { for attempt in 0..3 { match std::fs::remove_dir_all(dir) { Ok(()) => break, Err(_) if attempt < 2 => { std::thread::sleep(Duration::from_millis(100)); } Err(e) => { // Use write! instead of eprintln! to avoid panicking // if the daemon's stderr pipe is broken (parent dropped it). let _ = writeln!( std::io::stderr(), "Warning: failed to clean up temp profile {}: {}", dir.display(), e ); } } } } } } #[derive(Clone)] pub struct LaunchOptions { pub headless: bool, pub executable_path: Option, pub proxy: Option, pub proxy_bypass: Option, pub proxy_username: Option, pub proxy_password: Option, pub profile: Option, pub args: Vec, pub allow_file_access: bool, pub extensions: Option>, pub storage_state: Option, pub user_agent: Option, pub ignore_https_errors: bool, pub color_scheme: Option, pub download_path: Option, /// Hide native scrollbars in headless Chromium screenshots by launching /// Chrome with `--hide-scrollbars`. pub hide_scrollbars: bool, /// Initial viewport dimensions used for `--window-size` so the content /// area matches the desired viewport from the start. pub viewport_size: Option<(u32, u32)>, /// When true, omit `--password-store=basic` and `--use-mock-keychain` so /// Chrome uses the real system keychain. Set automatically when launching /// with a copied Chrome profile. pub use_real_keychain: bool, } impl Default for LaunchOptions { fn default() -> Self { Self { headless: true, executable_path: None, proxy: None, proxy_bypass: None, proxy_username: None, proxy_password: None, profile: None, args: Vec::new(), allow_file_access: false, extensions: None, storage_state: None, user_agent: None, ignore_https_errors: false, color_scheme: None, download_path: None, hide_scrollbars: true, viewport_size: None, use_real_keychain: false, } } } struct ChromeArgs { args: Vec, user_data_dir: PathBuf, temp_user_data_dir: Option, } /// Whether to launch Chrome headless. The stealth fork FORBIDS headless (it's a /// bot-detection tell), so this is `false` unless an operator explicitly opts in /// via `AGENT_BROWSER_ALLOW_HEADLESS=1` for a display-less server. The `headless` /// LaunchOption is intentionally ignored — headed is non-negotiable for stealth. fn launch_headless() -> bool { std::env::var("AGENT_BROWSER_ALLOW_HEADLESS") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false) } /// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched /// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior. fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> { let opt_in = std::env::var("AGENT_BROWSER_BLOCK_WEBRTC").ok(); let explicitly_off = opt_in .as_deref() .is_some_and(|v| v == "0" || v.eq_ignore_ascii_case("false")); if explicitly_off { return None; } let explicitly_on = opt_in .as_deref() .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")); if has_proxy { // Force all WebRTC UDP through the proxy so the real IP can't leak. Some("disable_non_proxied_udp") } else if explicitly_on { // No proxy, but the user asked to hide the local network IP. Some("default_public_interface_only") } else { None } } fn build_chrome_args(options: &LaunchOptions) -> Result { let mut args = vec![ "--remote-debugging-port=0".to_string(), "--no-first-run".to_string(), "--no-default-browser-check".to_string(), // Stealth: reduce automation fingerprint surface "--disable-blink-features=AutomationControlled".to_string(), "--use-gl=angle".to_string(), "--use-angle=default".to_string(), "--disable-background-networking".to_string(), "--disable-backgrounding-occluded-windows".to_string(), "--disable-component-update".to_string(), "--disable-default-apps".to_string(), "--disable-hang-monitor".to_string(), "--disable-popup-blocking".to_string(), "--disable-prompt-on-repost".to_string(), "--disable-sync".to_string(), "--disable-features=Translate".to_string(), "--enable-features=NetworkService,NetworkServiceInProcess".to_string(), "--metrics-recording-only".to_string(), ]; if !options.use_real_keychain { args.push("--password-store=basic".to_string()); args.push("--use-mock-keychain".to_string()); } let has_extensions = options .extensions .as_ref() .is_some_and(|exts| !exts.is_empty()); // Stealth fork: NEVER launch headless. Headless Chrome is a detectable tell // (creepjs scores ~33% headless even with new-headless; a real GPU and a // headed window score 0%). So we always launch headed and ignore the // `headless` option. The only escape is an explicit AGENT_BROWSER_ALLOW_HEADLESS=1 // for genuinely display-less servers (discouraged — it forfeits stealth). // Extensions also require headed mode (content scripts aren't injected headless). if launch_headless() && !has_extensions { args.push("--headless=new".to_string()); // Linux paints native scrollbars into viewport screenshots unless // Chrome is launched with this flag. `--hide-scrollbars` is // presence-based, so agent-browser exposes --hide-scrollbars false // as the public opt-out instead of forwarding a fake inverse switch. if options.hide_scrollbars { args.push("--hide-scrollbars".to_string()); } // Enable SwiftShader software rendering in headless mode. This // prevents silent crashes in environments where GPU drivers are // missing or restricted (VMs, containers, some cloud machines) // while preserving WebGL support. Playwright uses the same flag. args.push("--enable-unsafe-swiftshader".to_string()); } if let Some(ref proxy) = options.proxy { args.push(format!("--proxy-server={}", proxy)); } if let Some(ref bypass) = options.proxy_bypass { args.push(format!("--proxy-bypass-list={}", bypass)); } // WebRTC IP-leak handling. WebRTC enumerates ICE candidates that can expose // the machine's real local/public IP even when HTTP traffic goes through a // proxy — defeating the proxy. `--force-webrtc-ip-handling-policy` is a real // Chrome privacy switch (no detectable JS lie), applied here for launched // Chrome only (an attached real Chrome keeps the user's own flags). // - proxy set -> `disable_non_proxied_udp`: force WebRTC through // the proxy so the real IP can't leak. // - AGENT_BROWSER_BLOCK_WEBRTC=1 (no proxy) -> `default_public_interface_only`: // hide the local network IP (Brave/uBlock default). // Opt out entirely with AGENT_BROWSER_BLOCK_WEBRTC=0. if let Some(policy) = webrtc_ip_handling_policy(options.proxy.is_some()) { args.push(format!("--force-webrtc-ip-handling-policy={}", policy)); } let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile { let expanded = expand_tilde(profile); let dir = PathBuf::from(&expanded); args.push(format!("--user-data-dir={}", expanded)); (dir, None) } else { let dir = std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir) .map_err(|e| format!("Failed to create temp profile dir: {}", e))?; args.push(format!("--user-data-dir={}", dir.display())); (dir.clone(), Some(dir)) }; if options.ignore_https_errors { args.push("--ignore-certificate-errors".to_string()); } if options.allow_file_access { args.push("--allow-file-access-from-files".to_string()); args.push("--allow-file-access".to_string()); } if let Some(ref exts) = options.extensions { if !exts.is_empty() { let ext_list = exts.join(","); args.push(format!("--load-extension={}", ext_list)); args.push(format!("--disable-extensions-except={}", ext_list)); } } let has_window_size = options .args .iter() .any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size=")); if !has_window_size && launch_headless() && !has_extensions { let (w, h) = options.viewport_size.unwrap_or((1280, 720)); args.push(format!("--window-size={},{}", w, h)); } args.extend(options.args.iter().cloned()); if should_disable_sandbox(&args) { args.push("--no-sandbox".to_string()); } if should_disable_dev_shm(&args) { args.push("--disable-dev-shm-usage".to_string()); } Ok(ChromeArgs { args, user_data_dir, temp_user_data_dir, }) } pub fn launch_chrome(options: &LaunchOptions) -> Result { let chrome_path = match &options.executable_path { Some(p) => PathBuf::from(p), None => find_chrome().ok_or_else(|| { let cache_dir = crate::install::get_browsers_dir(); format!( "Chrome not found. Checked:\n \ - agent-browser cache: {}\n \ - System Chrome installations\n \ - Puppeteer browser cache\n \ - Playwright browser cache\n\ Run `agent-browser install` to download Chrome, or use --executable-path.", cache_dir.display() ) })?, }; // Profile name preprocessing: if --profile is a Chrome profile name (not a // path), resolve it to a directory, copy the profile to a temp dir, and // rewrite options so the retry loop uses the copied profile. let mut resolved_options: Option = None; let mut profile_temp_dir: Option = None; if let Some(ref profile) = options.profile { if is_chrome_profile_name(profile) { let user_data_dir = find_chrome_user_data_dir().ok_or_else(|| { "No Chrome user data directory found. Cannot resolve profile name.\n\ If you meant a directory path, use a full path (e.g., /path/to/profile)." .to_string() })?; let resolved = resolve_chrome_profile(&user_data_dir, profile)?; let temp_path = copy_chrome_profile(&user_data_dir, &resolved)?; let mut opts = options.clone(); opts.profile = Some(temp_path.display().to_string()); opts.use_real_keychain = true; opts.args.push(format!("--profile-directory={}", resolved)); profile_temp_dir = Some(temp_path); resolved_options = Some(opts); } } let effective_options = resolved_options.as_ref().unwrap_or(options); let max_attempts = 3; let mut last_err = String::new(); for attempt in 1..=max_attempts { match try_launch_chrome(&chrome_path, effective_options) { Ok(mut process) => { // Transfer profile temp dir ownership to ChromeProcess for cleanup on Drop. // The try_launch_chrome temp_user_data_dir is None here because we set profile // to the temp path (treated as a user-supplied path, no second temp dir). if let Some(ref dir) = profile_temp_dir { process.temp_user_data_dir = Some(dir.clone()); } return Ok(process); } Err(e) => { last_err = e; if attempt < max_attempts { // Use write! instead of eprintln! to avoid panicking // if the daemon's stderr pipe is broken (parent dropped it). let _ = writeln!( std::io::stderr(), "[chrome] Launch attempt {}/{} failed, retrying in 500ms...", attempt, max_attempts ); std::thread::sleep(Duration::from_millis(500)); } } } } // All retries failed: clean up profile temp dir if we created one if let Some(ref dir) = profile_temp_dir { let _ = std::fs::remove_dir_all(dir); } Err(last_err) } fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result { let ChromeArgs { args, user_data_dir, temp_user_data_dir, } = build_chrome_args(options)?; // Mitigate stale DevToolsActivePort risk (e.g., previous crash left it behind). // Puppeteer does similar cleanup before spawning. let _ = std::fs::remove_file(user_data_dir.join("DevToolsActivePort")); let cleanup_temp_dir = |dir: &Option| { if let Some(ref d) = dir { let _ = std::fs::remove_dir_all(d); } }; let mut cmd = Command::new(chrome_path); cmd.args(&args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); // Place Chrome in its own process group so we can kill the entire tree // (main process + GPU/renderer/utility/crashpad helpers) with a single // killpg(), preventing orphaned processes (issue #1113). // // NOTE: Do NOT use PR_SET_PDEATHSIG here. Chrome is spawned via // tokio::task::spawn_blocking, and PR_SET_PDEATHSIG fires when the // *thread* that forked the child exits, not the process. Tokio reaps // idle blocking threads after ~10s, which kills Chrome (issue #1157). #[cfg(unix)] { use std::os::unix::process::CommandExt; // SAFETY: pre_exec runs between fork() and exec() in the child. // setpgid is async-signal-safe. unsafe { cmd.pre_exec(|| { libc::setpgid(0, 0); Ok(()) }); } } let mut child = cmd.spawn().map_err(|e| { cleanup_temp_dir(&temp_user_data_dir); format!("Failed to launch Chrome at {:?}: {}", chrome_path, e) })?; // Shared overall deadline so we don't double-wait (poll + stderr fallback). let deadline = std::time::Instant::now() + Duration::from_secs(30); // Primary path: use DevToolsActivePort written into user-data-dir. // This is more reliable on Windows than scraping stderr for "DevTools listening on ...", // which can be missing/empty depending on how Chrome is launched. let ws_url = match wait_for_devtools_active_port(&mut child, &user_data_dir, deadline) { Ok(url) => url, Err(primary_err) => { // Fallback: scrape stderr (legacy behavior) for better diagnostics. let stderr = child.stderr.take().ok_or_else(|| { let _ = child.kill(); cleanup_temp_dir(&temp_user_data_dir); "Failed to capture Chrome stderr".to_string() })?; let reader = BufReader::new(stderr); match wait_for_ws_url_until(reader, deadline) { Ok(url) => url, Err(fallback_err) => { let _ = child.kill(); cleanup_temp_dir(&temp_user_data_dir); return Err(format!( "{}\n(also tried parsing stderr) {}", primary_err, fallback_err )); } } } }; #[cfg(unix)] let pgid = { let pid = child.id() as i32; // The child called setpgid(0,0) via process_group(0), so its PGID // equals its own PID. Some(pid) }; Ok(ChromeProcess { child, ws_url, temp_user_data_dir, #[cfg(unix)] pgid, }) } fn wait_for_devtools_active_port( child: &mut Child, user_data_dir: &Path, deadline: std::time::Instant, ) -> Result { let poll_interval = Duration::from_millis(50); while std::time::Instant::now() <= deadline { if let Ok(Some(status)) = child.try_wait() { // Chrome exited before writing DevToolsActivePort -- report the // exit code so the caller can surface it alongside stderr output. let code = status .code() .map(|c| format!("{}", c)) .unwrap_or_else(|| "unknown".to_string()); return Err(format!( "Chrome exited early (exit code: {}) without writing DevToolsActivePort", code )); } if let Some((port, ws_path)) = read_devtools_active_port(user_data_dir) { let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path); return Ok(ws_url); } std::thread::sleep(poll_interval); } Err("Timeout waiting for DevToolsActivePort".to_string()) } fn wait_for_ws_url_until( reader: BufReader, deadline: std::time::Instant, ) -> Result { let prefix = "DevTools listening on "; let mut stderr_lines: Vec = Vec::new(); for line in reader.lines() { if std::time::Instant::now() > deadline { return Err(chrome_launch_error( "Timeout waiting for Chrome DevTools URL", &stderr_lines, )); } let line = line.map_err(|e| format!("Failed to read Chrome stderr: {}", e))?; if let Some(url) = line.strip_prefix(prefix) { return Ok(url.trim().to_string()); } stderr_lines.push(line); } Err(chrome_launch_error( "Chrome exited before providing DevTools URL", &stderr_lines, )) } fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String { let relevant: Vec<&String> = stderr_lines .iter() .filter(|l| { let lower = l.to_lowercase(); lower.contains("error") || lower.contains("fatal") || lower.contains("sandbox") || lower.contains("namespace") || lower.contains("permission") || lower.contains("cannot") || lower.contains("failed") || lower.contains("abort") }) .collect(); if relevant.is_empty() { if stderr_lines.is_empty() { return format!( "{} (no stderr output from Chrome)\nHint: try passing --args \"--no-sandbox\" if Chrome crashes silently in your environment", message ); } let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect(); return format!( "{}\nChrome stderr (last {} lines):\n {}", message, last_lines.len(), last_lines .into_iter() .rev() .map(|s| s.as_str()) .collect::>() .join("\n ") ); } let hint = if relevant.iter().any(|l| { let lower = l.to_lowercase(); lower.contains("sandbox") || lower.contains("namespace") }) { "\nHint: try --args \"--no-sandbox\" (required in containers, VMs, and some Linux setups)" } else { "" }; format!( "{}\nChrome stderr:\n {}{}", message, relevant .iter() .map(|s| s.as_str()) .collect::>() .join("\n "), hint ) } pub fn find_chrome() -> Option { // 1. Check Chrome downloaded by `agent-browser install` if let Some(p) = crate::install::find_installed_chrome() { return Some(p); } // If the cache directory exists but no Chrome was found, warn -- this // likely means the cache is corrupted or the directory layout is unexpected. let cache_dir = crate::install::get_browsers_dir(); if cache_dir.exists() { let _ = writeln!( std::io::stderr(), "Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \ Falling back to system Chrome. Run `agent-browser install` to re-download.", cache_dir.display() ); } // 2. Check system-installed Chrome #[cfg(target_os = "macos")] { let candidates = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", ]; for c in &candidates { let p = PathBuf::from(c); if p.exists() { return Some(p); } } } #[cfg(target_os = "linux")] { let candidates = [ "google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "brave-browser", "brave-browser-stable", ]; for name in &candidates { if let Ok(output) = Command::new("which").arg(name).output() { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { return Some(PathBuf::from(path)); } } } } } #[cfg(target_os = "windows")] { let candidates = [ r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", ]; if let Ok(local) = std::env::var("LOCALAPPDATA") { let chrome = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"); if chrome.exists() { return Some(chrome); } let brave = PathBuf::from(&local).join(r"BraveSoftware\Brave-Browser\Application\brave.exe"); if brave.exists() { return Some(brave); } } for c in &candidates { let p = PathBuf::from(c); if p.exists() { return Some(p); } } } // 3. Fallback: check Puppeteer / Playwright browser caches if let Some(p) = find_puppeteer_chrome() { return Some(p); } if let Some(p) = find_playwright_chromium() { return Some(p); } None } pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> { let path = user_data_dir.join("DevToolsActivePort"); let content = std::fs::read_to_string(&path).ok()?; let mut lines = content.lines(); let port: u16 = lines.next()?.trim().parse().ok()?; let ws_path = lines .next() .unwrap_or("/devtools/browser") .trim() .to_string(); Some((port, ws_path)) } /// Remove leftover Chrome temp profile directories from daemons that were /// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a /// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB /// per session under the system temp dir. On daemon startup we sweep them — but /// ONLY dirs that no running process still references as `--user-data-dir`, so /// a profile in active use is never deleted. pub fn cleanup_orphaned_chrome_profiles() { let tmp = std::env::temp_dir(); let Ok(entries) = std::fs::read_dir(&tmp) else { return; }; // Snapshot live process command lines once. If we can't determine them, // skip cleanup entirely rather than risk deleting an in-use profile. let Some(live_cmdlines) = running_process_cmdlines() else { return; }; for entry in entries.flatten() { let name = entry.file_name(); if !name.to_string_lossy().starts_with("agent-browser-chrome-") { continue; } let path = entry.path(); let path_str = path.to_string_lossy(); let in_use = live_cmdlines .iter() .any(|cmd| cmd.contains(path_str.as_ref())); if !in_use { let _ = std::fs::remove_dir_all(&path); } } } #[cfg(unix)] fn running_process_cmdlines() -> Option> { let output = std::process::Command::new("ps") .args(["-axww", "-o", "command="]) .output() .ok()?; if !output.status.success() { return None; } Some( String::from_utf8_lossy(&output.stdout) .lines() .map(|l| l.to_string()) .collect(), ) } #[cfg(not(unix))] fn running_process_cmdlines() -> Option> { // Best-effort: skip cleanup where we can't cheaply enumerate full process // command lines, to avoid deleting a profile that is still in use. None } pub async fn auto_connect_cdp() -> Result { // Prefer the dialog-free `ab-connect` extension relay when it is live. // The relay drives the user's REAL Chrome via the extension's // `chrome.debugger` permission, which — unlike a raw `--remote-debugging-port` // CDP attach — never triggers Chrome 136+'s per-connection // "Allow remote debugging?" consent modal. The native-messaging host writes // ~/.agent-browser/relay-cdp-url while connected and removes it on exit, so a // present URL means the relay is up. This must win over the DevToolsActivePort // / :9222 probes below: if the user's Chrome happens to also be listening on a // debug port, attaching there would pop the consent dialog and defeat the // whole zero-interaction extension path. // If the extension is installed, it is the *intended* transport. The relay // URL file comes and goes with the MV3 service worker (a Chrome restart or an // idle SW briefly drops it), so a single failed probe doesn't mean "no // extension" — retry for a few seconds while it reconnects. Crucially, when // the extension is set up we must NEVER fall through to the raw :9222 path // below: that pops Chrome 136+'s "Allow remote debugging?" dialog, the exact // thing the extension exists to avoid. // ~15s of retries (500ms apart) when the extension is installed: long enough // for the MV3 service worker to wake and reconnect on its own (onStartup // after a Chrome restart, or the keepalive alarm) so the relay self-heals // with NO user action. The loop re-checks the relay file every iteration, so // a recovery mid-wait is picked up immediately — the full window is only ever // spent when the extension is genuinely down. let host_installed = crate::connect::host_installed(); let relay_attempts = if host_installed { 30 } else { 1 }; for attempt in 0..relay_attempts { if let Some(relay) = crate::connect::relay_url() { // The relay is a local CDP-over-WS endpoint we connect to like Chrome. // A bare TCP liveness check (no WS upgrade) confirms it is actually // accepting before we commit, mirroring the consent-free probe used // for DevToolsActivePort. if relay_is_live(&relay).await { return Ok(relay); } } if host_installed && attempt + 1 < relay_attempts { tokio::time::sleep(std::time::Duration::from_millis(500)).await; } } if host_installed { return Err( "The agent-browser-stealth extension is installed, but its relay \ isn't connected right now. Wake it up — click the extension's \ toolbar icon, or reload it at chrome://extensions — then retry. \ (agent-browser will not attach to a raw --remote-debugging-port \ while the extension is set up, because that pops Chrome's \"Allow \ remote debugging?\" dialog. Use --cdp to force the raw path.)" .to_string(), ); } let user_data_dirs = get_chrome_user_data_dirs(); for dir in &user_data_dirs { if let Some((port, ws_path)) = read_devtools_active_port(dir) { if let Ok(ws_url) = resolve_cdp_from_active_port(port, &ws_path).await { return Ok(ws_url); } // Port is dead — remove the stale file so future runs skip it. let stale = dir.join("DevToolsActivePort"); let _ = std::fs::remove_file(&stale); } } // Fallback: probe common ports for port in [9222u16, 9229] { if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await { return Ok(ws_url); } } Err( "No running Chrome with remote debugging found. Remote debugging is a \ startup flag, not a setting: fully quit Chrome and relaunch it with \ --remote-debugging-port=9222 (then agent-browser auto-connects), or pass \ --cdp /--launch." .to_string(), ) } /// Resolve a CDP WebSocket URL from a DevToolsActivePort entry. /// /// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only /// by a consent-free TCP liveness check. Falls back to HTTP discovery on the /// same port for older Chrome layouts. /// /// Crucially, this does NOT open a throwaway verification WebSocket. On /// Chrome 136+ the "Allow remote debugging?" consent is granted *per /// connection*: a probe WebSocket we then close would consume the user's one /// Allow click, leaving the real connection (opened afterwards) unconsented — /// which manifests as an endless prompt loop or a hung command. By skipping the /// probe, the real connection is the single WebSocket the user consents to. /// (Background: #1210, #1206 duplicate-prompt reports.) async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result { // Consent-free liveness: a bare TCP connect does not trigger the // remote-debugging consent flow (that fires on the CDP/WebSocket upgrade), // so we can tell "Chrome is listening" from "stale DevToolsActivePort" // without burning a prompt. if tcp_port_alive(port).await { return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path)); } // Port isn't accepting connections (stale file / different layout). Fall // back to HTTP discovery for older Chrome before giving up. if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await { return Ok(ws_url); } Err(format!( "Cannot connect to Chrome on port {}: port not reachable and HTTP discovery failed", port )) } /// Consent-free check that something is accepting TCP connections on /// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not /// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to /// use for liveness before handing the URL to the single real connection. async fn tcp_port_alive(port: u16) -> bool { let timeout = Duration::from_secs(1); matches!( tokio::time::timeout(timeout, tokio::net::TcpStream::connect(("127.0.0.1", port)),).await, Ok(Ok(_)) ) } /// Consent-free liveness for the `ab-connect` relay ws URL (`ws://127.0.0.1:/…`). /// Parses the port and does a bare TCP connect — a stale relay-cdp-url file /// (host exited without cleanup) must not divert auto-connect away from the /// working port path. async fn relay_is_live(ws_url: &str) -> bool { let port = ws_url .strip_prefix("ws://") .and_then(|rest| rest.split('/').next()) .and_then(|hostport| hostport.rsplit(':').next()) .and_then(|p| p.parse::().ok()); match port { Some(p) => tcp_port_alive(p).await, None => false, } } /// Returns the default Chrome user-data directory paths for the current platform. /// Includes Chrome, Chrome Canary, Chromium, and Brave. pub fn get_chrome_user_data_dirs() -> Vec { let mut dirs = Vec::new(); #[cfg(target_os = "macos")] { if let Some(home) = dirs::home_dir() { let base = home.join("Library/Application Support"); for name in [ "Google/Chrome", "Google/Chrome Canary", "Chromium", "BraveSoftware/Brave-Browser", ] { dirs.push(base.join(name)); } } } #[cfg(target_os = "linux")] { if let Some(home) = dirs::home_dir() { let config = home.join(".config"); for name in [ "google-chrome", "google-chrome-unstable", "chromium", "BraveSoftware/Brave-Browser", ] { dirs.push(config.join(name)); } } } #[cfg(target_os = "windows")] { if let Ok(local) = std::env::var("LOCALAPPDATA") { let base = PathBuf::from(local); for name in [ r"Google\Chrome\User Data", r"Google\Chrome SxS\User Data", r"Chromium\User Data", r"BraveSoftware\Brave-Browser\User Data", ] { dirs.push(base.join(name)); } } } dirs } /// Returns true if the given string looks like a Chrome profile name rather than /// a file path. A profile name contains no `/`, `\`, or `~` characters. pub fn is_chrome_profile_name(s: &str) -> bool { !s.contains('/') && !s.contains('\\') && !s.contains('~') } /// Returns the first existing Chrome user-data directory that contains a /// `Local State` file. pub fn find_chrome_user_data_dir() -> Option { get_chrome_user_data_dirs() .into_iter() .find(|dir| dir.join("Local State").is_file()) } /// A Chrome profile entry parsed from `Local State`. #[derive(Debug, Clone)] pub struct ChromeProfile { /// The directory name (e.g., "Default", "Profile 1"). pub directory: String, /// The user-visible display name (e.g., "Person 1"). pub name: String, } /// Lists all Chrome profiles found in the given user-data directory by reading /// the `Local State` JSON file. Returns an empty vec if the file is missing, /// malformed, or lacks the expected `profile.info_cache` key. pub fn list_chrome_profiles(user_data_dir: &Path) -> Vec { let local_state_path = user_data_dir.join("Local State"); let content = match std::fs::read_to_string(&local_state_path) { Ok(c) => c, Err(_) => return Vec::new(), }; let json: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(_) => return Vec::new(), }; let info_cache = match json.get("profile").and_then(|p| p.get("info_cache")) { Some(obj) if obj.is_object() => obj.as_object().unwrap(), _ => return Vec::new(), }; let mut profiles: Vec = info_cache .iter() .map(|(dir_name, info)| { let display_name = info .get("name") .and_then(|n| n.as_str()) .unwrap_or(dir_name) .to_string(); ChromeProfile { directory: dir_name.clone(), name: display_name, } }) .collect(); profiles.sort_by(|a, b| a.directory.cmp(&b.directory)); profiles } /// Resolves a profile input string to a Chrome profile directory name using /// three-tier matching: /// 1. Exact directory name match /// 2. Case-insensitive display name match (error if ambiguous) /// 3. Case-insensitive directory name match /// /// Returns the resolved directory name, or an error with available profiles. /// Read `profile.last_used` (the directory name of the profile Chrome opened /// most recently) from a user-data dir's `Local State`. Used to resolve /// `--profile auto`. fn read_last_used_profile(user_data_dir: &Path) -> Option { let content = std::fs::read_to_string(user_data_dir.join("Local State")).ok()?; let json: serde_json::Value = serde_json::from_str(&content).ok()?; json.get("profile")? .get("last_used")? .as_str() .map(String::from) } pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result { let profiles = list_chrome_profiles(user_data_dir); if profiles.is_empty() { return Err(format!( "No Chrome profiles found in {}.\n\ If you meant a directory path, use a full path (e.g., /path/to/profile).", user_data_dir.display() )); } // "auto": pick the profile Chrome last used (else "Default", else the first // one), so `--profile auto` reuses the real logged-in profile without the // user having to name it explicitly. if input.eq_ignore_ascii_case("auto") { if let Some(lu) = read_last_used_profile(user_data_dir) { if let Some(p) = profiles.iter().find(|p| p.directory == lu) { return Ok(p.directory.clone()); } } if let Some(p) = profiles.iter().find(|p| p.directory == "Default") { return Ok(p.directory.clone()); } return Ok(profiles[0].directory.clone()); } // Tier 1: exact directory name match if let Some(p) = profiles.iter().find(|p| p.directory == input) { return Ok(p.directory.clone()); } // Tier 2: case-insensitive display name match let input_lower = input.to_lowercase(); let display_matches: Vec<&ChromeProfile> = profiles .iter() .filter(|p| p.name.to_lowercase() == input_lower) .collect(); match display_matches.len() { 1 => return Ok(display_matches[0].directory.clone()), n if n > 1 => { return Err(format!( "Ambiguous profile name \"{}\". Multiple profiles match:\n{}\n\ Use the directory name instead.", input, format_profile_list(&display_matches) )); } _ => {} } // Tier 3: case-insensitive directory name match if let Some(p) = profiles .iter() .find(|p| p.directory.to_lowercase() == input_lower) { return Ok(p.directory.clone()); } let all_profiles: Vec<&ChromeProfile> = profiles.iter().collect(); Err(format!( "Chrome profile \"{}\" not found. Available profiles:\n{}\n\ If you meant a directory path, use a full path (e.g., /path/to/profile).", input, format_profile_list(&all_profiles) )) } fn format_profile_list(profiles: &[&ChromeProfile]) -> String { profiles .iter() .map(|p| format!(" {} ({})", p.directory, p.name)) .collect::>() .join("\n") } /// Directories to exclude when copying a Chrome profile. These are large /// non-auth directories that are not needed for reusing login state. const PROFILE_COPY_EXCLUDE_DIRS: &[&str] = &[ "Cache", "Code Cache", "GPUCache", "Service Worker", "blob_storage", "File System", "GCM Store", "optimization_guide", "ShaderCache", "component_crx_cache", ]; /// Copies a Chrome profile subdirectory and `Local State` to a temp directory /// with a two-level structure suitable for `--user-data-dir`. Returns the temp /// directory path on success. /// /// The copy is best-effort: individual file failures (e.g., `SingletonLock`) /// are skipped with a warning. If the source profile directory is missing or /// the temp dir cannot be created, returns an error after cleaning up. pub fn copy_chrome_profile( user_data_dir: &Path, profile_directory: &str, ) -> Result { let temp_dir = std::env::temp_dir().join(format!("agent-browser-profile-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&temp_dir) .map_err(|e| format!("Failed to create temp profile dir: {}", e))?; // Copy Local State (non-fatal if missing or unreadable) let local_state_src = user_data_dir.join("Local State"); if let Err(e) = std::fs::copy(&local_state_src, temp_dir.join("Local State")) { let _ = writeln!( std::io::stderr(), "Warning: could not copy Local State from {}: {}", local_state_src.display(), e ); } // Copy profile subdirectory let src_profile = user_data_dir.join(profile_directory); if !src_profile.is_dir() { let _ = std::fs::remove_dir_all(&temp_dir); return Err(format!( "Profile directory not found: {}", src_profile.display() )); } let dst_profile = temp_dir.join(profile_directory); if let Err(e) = copy_dir_recursive(&src_profile, &dst_profile) { let _ = std::fs::remove_dir_all(&temp_dir); return Err(format!("Failed to copy profile: {}", e)); } Ok(temp_dir) } /// Recursively copies a directory, skipping entries in [`PROFILE_COPY_EXCLUDE_DIRS`]. /// Individual file copy failures are logged to stderr but do not fail the operation. fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { std::fs::create_dir_all(dst) .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?; let entries = std::fs::read_dir(src) .map_err(|e| format!("Failed to read directory {}: {}", src.display(), e))?; for entry in entries { let entry = match entry { Ok(e) => e, Err(e) => { let _ = writeln!( std::io::stderr(), "Warning: failed to read entry in {}: {}", src.display(), e ); continue; } }; let name = entry.file_name(); let name_str = name.to_string_lossy(); let src_path = entry.path(); let dst_path = dst.join(&name); let file_type = match entry.file_type() { Ok(ft) => ft, Err(e) => { let _ = writeln!( std::io::stderr(), "Warning: failed to get file type for {}: {}", src_path.display(), e ); continue; } }; if file_type.is_dir() { if PROFILE_COPY_EXCLUDE_DIRS.contains(&name_str.as_ref()) { continue; } copy_dir_recursive(&src_path, &dst_path)?; } else if let Err(e) = std::fs::copy(&src_path, &dst_path) { let _ = writeln!( std::io::stderr(), "Warning: failed to copy {}: {}", src_path.display(), e ); } } Ok(()) } /// Returns true if Chrome's sandbox should be disabled because the environment /// doesn't support it (containers, VMs, CI runners, running as root). fn should_disable_sandbox(existing_args: &[String]) -> bool { if existing_args.iter().any(|a| a == "--no-sandbox") { return false; // already set by user } // CI environments (GitHub Actions, GitLab CI, etc.) often lack user namespace // support due to AppArmor or kernel restrictions. if std::env::var("CI").is_ok() { return true; } #[cfg(unix)] { // Root user -- standard container default, Chrome sandbox requires non-root if unsafe { libc::geteuid() } == 0 { return true; } // Docker container if Path::new("/.dockerenv").exists() { return true; } // Podman container if Path::new("/run/.containerenv").exists() { return true; } // Generic container detection: cgroup contains docker/kubepods/lxc if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") { if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") { return true; } } } false } /// Returns true if Chrome should use disk instead of /dev/shm for shared memory. /// On CI runners and containers, /dev/shm is often too small (64MB default), /// which causes Chrome to crash mid-session. fn should_disable_dev_shm(existing_args: &[String]) -> bool { if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") { return false; } if std::env::var("CI").is_ok() { return true; } #[cfg(unix)] { if unsafe { libc::geteuid() } == 0 { return true; } if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() { return true; } if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") { if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") { return true; } } } false } /// Search Puppeteer's browser cache for a Chrome binary. /// Puppeteer v19+ stores Chrome in ~/.cache/puppeteer/chrome/-/ fn find_puppeteer_chrome() -> Option { let mut search_dirs = Vec::new(); if let Ok(custom) = std::env::var("PUPPETEER_CACHE_DIR") { search_dirs.push(PathBuf::from(custom).join("chrome")); } if let Some(home) = dirs::home_dir() { search_dirs.push(home.join(".cache/puppeteer/chrome")); } for dir in &search_dirs { if !dir.is_dir() { continue; } if let Ok(entries) = std::fs::read_dir(dir) { let mut matches: Vec = entries .filter_map(|e| e.ok()) .filter(|e| e.path().is_dir()) .filter_map(|e| { let candidate = build_puppeteer_binary_path(&e.path()); if candidate.exists() { Some(candidate) } else { None } }) .collect(); matches.sort(); matches.reverse(); if let Some(p) = matches.into_iter().next() { return Some(p); } } } None } #[cfg(target_os = "linux")] fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf { version_dir.join("chrome-linux64/chrome") } #[cfg(target_os = "macos")] fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf { // Puppeteer uses chrome-mac-arm64 or chrome-mac-x64 depending on arch let arm = version_dir.join( "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", ); if arm.exists() { return arm; } version_dir.join( "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", ) } #[cfg(target_os = "windows")] fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf { version_dir.join(r"chrome-win64\chrome.exe") } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf { version_dir.join("chrome") } /// Search Playwright's browser cache for a Chromium binary. /// Legacy fallback for users who previously installed Chromium via Playwright. fn find_playwright_chromium() -> Option { let mut search_dirs = Vec::new(); if let Ok(custom) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") { search_dirs.push(PathBuf::from(custom)); } if let Some(home) = dirs::home_dir() { search_dirs.push(home.join(".cache/ms-playwright")); } for dir in &search_dirs { if !dir.is_dir() { continue; } if let Ok(entries) = std::fs::read_dir(dir) { let mut matches: Vec = entries .filter_map(|e| e.ok()) .filter(|e| { e.file_name() .to_str() .map(|n| n.starts_with("chromium-")) .unwrap_or(false) }) .filter_map(|e| { let candidate = build_playwright_binary_path(&e.path()); if candidate.exists() { Some(candidate) } else { None } }) .collect(); // Sort descending so the newest version wins matches.sort(); matches.reverse(); if let Some(p) = matches.into_iter().next() { return Some(p); } } } None } #[cfg(target_os = "linux")] fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf { chromium_dir.join("chrome-linux64/chrome") } #[cfg(target_os = "macos")] fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf { chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium") } #[cfg(target_os = "windows")] fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf { chromium_dir.join("chrome-win/chrome.exe") } fn expand_tilde(path: &str) -> String { if let Some(rest) = path.strip_prefix('~') { if let Some(home) = dirs::home_dir() { return home .join(rest.strip_prefix('/').unwrap_or(rest)) .to_string_lossy() .to_string(); } } path.to_string() } #[cfg(test)] mod tests { use super::*; use crate::test_utils::EnvGuard; #[test] fn webrtc_policy_forces_proxy_when_proxy_set() { let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]); g.remove("AGENT_BROWSER_BLOCK_WEBRTC"); // Proxy set, no env: always force WebRTC through the proxy. assert_eq!( webrtc_ip_handling_policy(true), Some("disable_non_proxied_udp") ); // No proxy, no env: leave WebRTC at Chrome's default. assert_eq!(webrtc_ip_handling_policy(false), None); } #[test] fn webrtc_policy_opt_in_and_opt_out() { let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]); g.set("AGENT_BROWSER_BLOCK_WEBRTC", "1"); assert_eq!( webrtc_ip_handling_policy(false), Some("default_public_interface_only") ); // Explicit opt-out wins even when a proxy is set. g.set("AGENT_BROWSER_BLOCK_WEBRTC", "0"); assert_eq!(webrtc_ip_handling_policy(true), None); assert_eq!(webrtc_ip_handling_policy(false), None); g.remove("AGENT_BROWSER_BLOCK_WEBRTC"); } #[cfg(unix)] fn spawn_noop_child() -> Child { Command::new("/bin/sh") .args(["-c", "exit 0"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .unwrap() } #[cfg(windows)] fn spawn_noop_child() -> Child { Command::new("cmd.exe") .args(["/C", "exit 0"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .unwrap() } #[test] fn test_find_chrome_returns_some_on_host() { // This test only makes sense on systems with Chrome installed if cfg!(target_os = "macos") || cfg!(target_os = "linux") { let result = find_chrome(); // Don't assert Some -- CI may not have Chrome if let Some(path) = result { assert!(path.exists()); } } } #[test] fn test_expand_tilde() { let expanded = expand_tilde("~/test/path"); assert!(!expanded.starts_with('~')); assert!(expanded.ends_with("test/path")); } #[test] fn test_expand_tilde_no_tilde() { assert_eq!(expand_tilde("/absolute/path"), "/absolute/path"); } #[test] fn test_read_devtools_active_port_missing() { let result = read_devtools_active_port(Path::new("/nonexistent")); assert!(result.is_none()); } #[test] fn test_should_disable_sandbox_skips_if_already_set() { let args = vec!["--headless=new".to_string(), "--no-sandbox".to_string()]; assert!(!should_disable_sandbox(&args)); } #[test] fn test_chrome_launch_error_no_stderr() { let msg = chrome_launch_error("Chrome exited", &[]); assert!(msg.contains("no stderr output")); assert!(msg.contains("Hint:")); assert!(msg.contains("--no-sandbox")); } #[test] fn test_chrome_launch_error_with_sandbox_hint() { let lines = vec![ "some log line".to_string(), "Failed to move to new namespace: sandbox error".to_string(), ]; let msg = chrome_launch_error("Chrome exited", &lines); assert!(msg.contains("sandbox error")); assert!(msg.contains("Hint:")); assert!(msg.contains("--no-sandbox")); } #[test] fn test_chrome_launch_error_generic() { let lines = vec!["info line".to_string(), "another info line".to_string()]; let msg = chrome_launch_error("Chrome exited", &lines); assert!(msg.contains("last 2 lines")); } #[test] fn test_find_playwright_chromium_nonexistent() { let guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH", "HOME", "USERPROFILE"]); guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path"); let temp_home = std::env::temp_dir().join(format!( "agent-browser-test-home-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock should be after unix epoch") .as_nanos() )); std::fs::create_dir_all(&temp_home).expect("temp home should be created"); let temp_home = temp_home.to_string_lossy().to_string(); guard.set("HOME", &temp_home); guard.set("USERPROFILE", &temp_home); let result = find_playwright_chromium(); assert!(result.is_none()); } #[test] fn test_build_args_forbids_headless_by_default() { // Stealth fork: headless is FORBIDDEN. `headless: true` is ignored — the // launch is always headed (no --headless / swiftshader / forced size). let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]); g.remove("AGENT_BROWSER_ALLOW_HEADLESS"); let opts = LaunchOptions { headless: true, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!( !result.args.iter().any(|a| a.contains("--headless")), "headless must be forbidden even when the headless option is true" ); assert!(!result .args .iter() .any(|a| a == "--enable-unsafe-swiftshader")); if let Some(dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(&dir); } } #[test] fn test_build_args_allow_headless_escape() { // The only way back to headless: an explicit opt-in for display-less servers. let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]); g.set("AGENT_BROWSER_ALLOW_HEADLESS", "1"); let opts = LaunchOptions { headless: true, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(result.args.iter().any(|a| a == "--headless=new")); assert!(result.args.iter().any(|a| a == "--window-size=1280,720")); if let Some(dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(&dir); } } #[test] fn test_build_args_headed_no_headless_flag() { let opts = LaunchOptions { headless: false, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(!result.args.iter().any(|a| a.contains("--headless"))); assert!(!result.args.iter().any(|a| a == "--hide-scrollbars")); assert!(!result .args .iter() .any(|a| a == "--enable-unsafe-swiftshader")); assert!(!result.args.iter().any(|a| a.starts_with("--window-size="))); // Temp dir created when no profile assert!(result.temp_user_data_dir.is_some()); let dir = result.temp_user_data_dir.unwrap(); assert!(dir.exists()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_build_args_temp_user_data_dir_created() { let opts = LaunchOptions::default(); let result = build_chrome_args(&opts).unwrap(); let dir = result.temp_user_data_dir.as_ref().unwrap(); assert!(dir.exists()); assert!(result .args .iter() .any(|a| a.starts_with("--user-data-dir="))); let _ = std::fs::remove_dir_all(dir); } #[test] fn test_build_args_profile_no_temp_dir() { let opts = LaunchOptions { profile: Some("/tmp/my-profile".to_string()), ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(result.temp_user_data_dir.is_none()); assert!(result .args .iter() .any(|a| a == "--user-data-dir=/tmp/my-profile")); } #[test] fn test_build_args_custom_window_size_not_overridden() { let opts = LaunchOptions { headless: true, args: vec!["--window-size=1920,1080".to_string()], ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(!result.args.iter().any(|a| a == "--window-size=1280,720")); assert!(result.args.iter().any(|a| a == "--window-size=1920,1080")); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() { let opts = LaunchOptions { headless: true, hide_scrollbars: false, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!( !result.args.iter().any(|a| a == "--hide-scrollbars"), "--hide-scrollbars false should suppress agent-browser's default hide switch" ); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_start_maximized_suppresses_default_window_size() { let opts = LaunchOptions { headless: true, args: vec!["--start-maximized".to_string()], ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(!result.args.iter().any(|a| a == "--window-size=1280,720")); assert!(result.args.iter().any(|a| a == "--start-maximized")); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_disables_translate() { let opts = LaunchOptions::default(); let result = build_chrome_args(&opts).unwrap(); assert!(result .args .iter() .any(|a| a.contains("--disable-features") && a.contains("Translate"))); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_headless_with_extensions_skips_headless_flag() { let opts = LaunchOptions { headless: true, extensions: Some(vec!["/tmp/my-ext".to_string()]), ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!( !result.args.iter().any(|a| a.contains("--headless")), "headless flag should be omitted when extensions are present" ); assert!( !result.args.iter().any(|a| a == "--hide-scrollbars"), "scrollbars should remain visible when extensions force headed mode" ); assert!( !result.args.iter().any(|a| a.contains("--window-size")), "window-size should be omitted when extensions force headed mode" ); assert!(result .args .iter() .any(|a| a.starts_with("--load-extension="))); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_headed_with_extensions_no_headless_flag() { let opts = LaunchOptions { headless: false, extensions: Some(vec!["/tmp/my-ext".to_string()]), ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!( !result.args.iter().any(|a| a.contains("--headless")), "headless flag should not be present in headed mode" ); assert!(result .args .iter() .any(|a| a.starts_with("--load-extension="))); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_ignore_https_errors_includes_flag() { let opts = LaunchOptions { ignore_https_errors: true, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(result .args .iter() .any(|a| a == "--ignore-certificate-errors")); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_ignore_https_errors_default_no_flag() { let opts = LaunchOptions::default(); let result = build_chrome_args(&opts).unwrap(); assert!(!result .args .iter() .any(|a| a == "--ignore-certificate-errors")); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_chrome_process_drop_cleans_temp_dir() { let dir = std::env::temp_dir().join(format!( "agent-browser-chrome-drop-test-{}", uuid::Uuid::new_v4() )); let _ = std::fs::create_dir_all(&dir); assert!(dir.exists()); { // Simulate a ChromeProcess with a temp dir but a dummy child. // We can't actually spawn Chrome here, but we can verify the Drop // logic by creating a small helper process. let child = spawn_noop_child(); let _process = ChromeProcess { child, ws_url: String::new(), temp_user_data_dir: Some(dir.clone()), #[cfg(unix)] pgid: None, }; // _process dropped here } assert!(!dir.exists(), "Temp dir should be cleaned up on drop"); } #[test] fn test_is_chrome_profile_name_simple() { assert!(is_chrome_profile_name("Default")); assert!(is_chrome_profile_name("Profile 1")); assert!(is_chrome_profile_name("")); } #[test] fn test_is_chrome_profile_name_paths() { assert!(!is_chrome_profile_name("/tmp/dir")); assert!(!is_chrome_profile_name("~/my-profile")); assert!(!is_chrome_profile_name("C:\\Users\\foo")); assert!(!is_chrome_profile_name("relative/path")); } #[test] fn test_resolve_chrome_profile_auto_prefers_last_used() { let tmp = std::env::temp_dir().join("ab-auto-lastused-test"); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); let local_state = serde_json::json!({ "profile": { "last_used": "Profile 2", "info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} } } }); std::fs::write( tmp.join("Local State"), serde_json::to_string(&local_state).unwrap(), ) .unwrap(); assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Profile 2"); assert_eq!(resolve_chrome_profile(&tmp, "AUTO").unwrap(), "Profile 2"); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn test_resolve_chrome_profile_auto_falls_back_to_default() { let tmp = std::env::temp_dir().join("ab-auto-default-test"); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); let local_state = serde_json::json!({ "profile": { "info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} } } }); std::fs::write( tmp.join("Local State"), serde_json::to_string(&local_state).unwrap(), ) .unwrap(); assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Default"); let _ = std::fs::remove_dir_all(&tmp); } /// Helper to create a fake Chrome user-data dir with a `Local State` file. fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) { let mut info_cache = serde_json::Map::new(); for (dir_name, display_name) in profiles { let mut entry = serde_json::Map::new(); entry.insert( "name".to_string(), serde_json::Value::String(display_name.to_string()), ); info_cache.insert(dir_name.to_string(), serde_json::Value::Object(entry)); } let local_state = serde_json::json!({ "profile": { "info_cache": info_cache } }); std::fs::create_dir_all(base).unwrap(); std::fs::write( base.join("Local State"), serde_json::to_string_pretty(&local_state).unwrap(), ) .unwrap(); } /// RAII guard that removes the temp directory on drop (even on panic). struct TempDir(PathBuf); impl TempDir { fn new(name: &str) -> Self { Self(std::env::temp_dir().join(format!( "agent-browser-test-{}-{}-{}", name, std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() ))) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } impl std::ops::Deref for TempDir { type Target = PathBuf; fn deref(&self) -> &PathBuf { &self.0 } } #[test] fn test_list_chrome_profiles_valid() { let dir = TempDir::new("list-profiles"); create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); let profiles = list_chrome_profiles(&dir); assert_eq!(profiles.len(), 2); assert_eq!(profiles[0].directory, "Default"); assert_eq!(profiles[0].name, "Person 1"); assert_eq!(profiles[1].directory, "Profile 1"); assert_eq!(profiles[1].name, "Work"); } #[test] fn test_list_chrome_profiles_missing_local_state() { let dir = TempDir::new("list-profiles-missing"); std::fs::create_dir_all(&*dir).unwrap(); let profiles = list_chrome_profiles(&dir); assert!(profiles.is_empty()); } #[test] fn test_list_chrome_profiles_malformed_json() { let dir = TempDir::new("list-profiles-malformed"); std::fs::create_dir_all(&*dir).unwrap(); std::fs::write(dir.join("Local State"), "not json").unwrap(); let profiles = list_chrome_profiles(&dir); assert!(profiles.is_empty()); } #[test] fn test_list_chrome_profiles_missing_info_cache() { let dir = TempDir::new("list-profiles-no-cache"); std::fs::create_dir_all(&*dir).unwrap(); std::fs::write(dir.join("Local State"), r#"{"profile": {}}"#).unwrap(); let profiles = list_chrome_profiles(&dir); assert!(profiles.is_empty()); } #[test] fn test_resolve_chrome_profile_exact_directory() { let dir = TempDir::new("resolve-exact"); create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); let result = resolve_chrome_profile(&dir, "Default"); assert_eq!(result.unwrap(), "Default"); } #[test] fn test_resolve_chrome_profile_display_name_case_insensitive() { let dir = TempDir::new("resolve-display"); create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); let result = resolve_chrome_profile(&dir, "work"); assert_eq!(result.unwrap(), "Profile 1"); } #[test] fn test_resolve_chrome_profile_directory_name_case_insensitive() { let dir = TempDir::new("resolve-dir-ci"); create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); let result = resolve_chrome_profile(&dir, "default"); assert_eq!(result.unwrap(), "Default"); } #[test] fn test_resolve_chrome_profile_not_found() { let dir = TempDir::new("resolve-notfound"); create_fake_local_state(&dir, &[("Default", "Person 1")]); let result = resolve_chrome_profile(&dir, "Nonexistent"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("not found")); assert!(err.contains("Default")); assert!(err.contains("full path")); } #[test] fn test_resolve_chrome_profile_ambiguous_display_name() { let dir = TempDir::new("resolve-ambiguous"); create_fake_local_state(&dir, &[("Default", "Work"), ("Profile 1", "Work")]); let result = resolve_chrome_profile(&dir, "Work"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Ambiguous")); assert!(err.contains("Default")); assert!(err.contains("Profile 1")); } /// Helper to create a fake Chrome profile directory with some files. fn create_fake_profile(user_data_dir: &Path, profile_dir: &str) { let profile_path = user_data_dir.join(profile_dir); std::fs::create_dir_all(profile_path.join("Local Storage/leveldb")).unwrap(); std::fs::write(profile_path.join("Cookies"), "fake-cookies").unwrap(); std::fs::write( profile_path.join("Local Storage/leveldb/CURRENT"), "fake-leveldb", ) .unwrap(); // Create an excluded directory to verify it's skipped std::fs::create_dir_all(profile_path.join("Cache")).unwrap(); std::fs::write(profile_path.join("Cache/data_0"), "cache-data").unwrap(); } #[test] fn test_copy_chrome_profile_structure() { let src = TempDir::new("copy-src"); create_fake_local_state(&src, &[("Default", "Person 1")]); create_fake_profile(&src, "Default"); let temp_path = copy_chrome_profile(&src, "Default").unwrap(); let temp = TempDir(temp_path); assert!(temp.join("Local State").is_file()); assert!(temp.join("Default/Cookies").is_file()); assert!(temp.join("Default/Local Storage/leveldb/CURRENT").is_file()); assert_eq!( std::fs::read_to_string(temp.join("Default/Cookies")).unwrap(), "fake-cookies" ); assert_eq!( std::fs::read_to_string(temp.join("Default/Local Storage/leveldb/CURRENT")).unwrap(), "fake-leveldb" ); assert!(!temp.join("Default/Cache").exists()); } #[test] fn test_copy_chrome_profile_missing_source() { let src = TempDir::new("copy-missing-src"); std::fs::create_dir_all(&*src).unwrap(); let result = copy_chrome_profile(&src, "Nonexistent"); assert!(result.is_err()); assert!(result.unwrap_err().contains("Profile directory not found")); } #[test] fn test_copy_chrome_profile_missing_local_state() { let src = TempDir::new("copy-no-ls"); let profile_path = src.join("Default"); std::fs::create_dir_all(&profile_path).unwrap(); std::fs::write(profile_path.join("Cookies"), "data").unwrap(); let temp_path = copy_chrome_profile(&src, "Default").unwrap(); let temp = TempDir(temp_path); assert!(!temp.join("Local State").exists()); assert!(temp.join("Default/Cookies").is_file()); } #[test] fn test_copy_dir_recursive_excludes() { let src = TempDir::new("copy-excludes-src"); let dst = TempDir::new("copy-excludes-dst"); std::fs::create_dir_all(src.join("keep")).unwrap(); std::fs::write(src.join("keep/data"), "keep-data").unwrap(); for excluded in PROFILE_COPY_EXCLUDE_DIRS { std::fs::create_dir_all(src.join(excluded)).unwrap(); std::fs::write(src.join(excluded).join("file"), "excluded").unwrap(); } copy_dir_recursive(&src, &dst).unwrap(); assert!(dst.join("keep/data").is_file()); for excluded in PROFILE_COPY_EXCLUDE_DIRS { assert!( !dst.join(excluded).exists(), "{} should be excluded", excluded ); } } #[test] fn test_build_args_use_real_keychain_true() { let opts = LaunchOptions { use_real_keychain: true, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!( !result.args.iter().any(|a| a == "--password-store=basic"), "should NOT have --password-store=basic when use_real_keychain is true" ); assert!( !result.args.iter().any(|a| a == "--use-mock-keychain"), "should NOT have --use-mock-keychain when use_real_keychain is true" ); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_use_real_keychain_false_default() { let opts = LaunchOptions::default(); let result = build_chrome_args(&opts).unwrap(); assert!( result.args.iter().any(|a| a == "--password-store=basic"), "should have --password-store=basic by default" ); assert!( result.args.iter().any(|a| a == "--use-mock-keychain"), "should have --use-mock-keychain by default" ); if let Some(ref dir) = result.temp_user_data_dir { let _ = std::fs::remove_dir_all(dir); } } #[test] fn test_build_args_profile_path_preserves_keychain_flags() { let opts = LaunchOptions { profile: Some("/tmp/my-profile".to_string()), ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(result .args .iter() .any(|a| a == "--user-data-dir=/tmp/my-profile")); assert!( result.args.iter().any(|a| a == "--password-store=basic"), "profile path should keep keychain flags" ); } // ------------------------------------------------------------------- // auto_connect_cdp discovery-order tests (#1210, #1206) // ------------------------------------------------------------------- /// When the port is live, `resolve_cdp_from_active_port` returns the exact /// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT /// probe with a verification WebSocket (which would burn Chrome 136+'s /// per-connection remote-debugging consent on a throwaway socket). #[tokio::test] async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() { // A bound listener makes the port TCP-reachable. We do NOT accept/serve // any WebSocket — resolve must succeed from the bare TCP check alone. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let ws_path = "/devtools/browser/test-uuid-1234"; let result = resolve_cdp_from_active_port(port, ws_path).await; assert!( result.is_ok(), "should succeed when port is live: {:?}", result ); assert_eq!( result.unwrap(), format!("ws://127.0.0.1:{}{}", port, ws_path), "should return the exact DevToolsActivePort URL untouched" ); drop(listener); } /// Regression guard for the consent storm: resolving the URL must only do a /// bare TCP connect, never a WebSocket/CDP handshake. On Chrome 136+ a /// handshake on a throwaway socket consumes the user's one "Allow remote /// debugging?" click, leaving the real connection unconsented (endless /// prompts / hang). #[tokio::test] async fn test_resolve_cdp_from_active_port_does_not_open_websocket() { use tokio::io::AsyncReadExt; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let server = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); // The liveness check connects then drops without writing anything. // Assert we receive no WebSocket upgrade bytes (EOF / no data). let mut buf = [0u8; 128]; let read = tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await; match read { Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"), Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine } }); let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await; assert_eq!( result.unwrap(), format!("ws://127.0.0.1:{}/devtools/browser/abc", port) ); server.await.unwrap(); } /// When neither ws_path nor HTTP discovery works, return an error. #[tokio::test] async fn test_resolve_cdp_from_active_port_both_fail() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); let result = resolve_cdp_from_active_port(port, "/devtools/browser/dead").await; assert!(result.is_err(), "should fail when nothing is listening"); } #[tokio::test] async fn test_relay_is_live_true_when_listening() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let url = format!("ws://127.0.0.1:{}/abc-guid", port); assert!( relay_is_live(&url).await, "relay_is_live should be true while the port is accepting" ); } #[tokio::test] async fn test_relay_is_live_false_when_dead() { // Bind to grab a free port, then drop so nothing is listening. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); let url = format!("ws://127.0.0.1:{}/abc-guid", port); assert!( !relay_is_live(&url).await, "relay_is_live must be false for a stale relay-cdp-url (host exited)" ); } #[tokio::test] async fn test_relay_is_live_false_on_malformed_url() { assert!(!relay_is_live("not-a-ws-url").await); assert!(!relay_is_live("ws://127.0.0.1/no-port").await); assert!(!relay_is_live("ws://127.0.0.1:notaport/x").await); } }