use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use std::io::Write; use std::path::PathBuf; use std::sync::atomic::AtomicU64; use std::sync::Arc; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use tokio::sync::{broadcast, oneshot, RwLock}; use crate::connection::{get_restore_url_path, get_socket_dir}; use super::auth; use super::browser::{should_track_target, BrowserManager, WaitUntil}; use super::cdp::chrome::LaunchOptions; use super::cdp::client::CdpClient; use super::cdp::types::{ AttachToTargetParams, AttachToTargetResult, CdpEvent, CreateTargetResult, DispatchMouseEventParams, ExceptionThrownEvent, JavascriptDialogOpeningEvent, TargetCreatedEvent, TargetDestroyedEvent, TargetInfoChangedEvent, }; use super::cookies; use super::diff; use super::element::RefMap; use super::humanize; use super::inspect_server::InspectServer; use super::interaction; use super::network::{self, DomainFilter, EventTracker}; use super::policy::{ActionPolicy, ConfirmActions, PolicyResult}; use super::providers; use super::react; use super::recording::{self, RecordingState}; use super::screenshot::{self, ScreenshotOptions}; use super::snapshot::{self, SnapshotOptions}; use super::state; use super::stealth; use super::storage; use super::stream::{self, StreamServer}; use super::tracing::{self as native_tracing, TracingState}; use super::webdriver::appium::AppiumManager; use super::webdriver::backend::{BrowserBackend, WebDriverBackend, WEBDRIVER_UNSUPPORTED_ACTIONS}; use super::webdriver::ios; use super::webdriver::safari; /// Wait strategy used by `auth_login` when navigating to the login page. /// /// We intentionally use `Load` (instead of `NetworkIdle`) because many modern /// apps keep background requests active indefinitely (polling, analytics, /// websockets), which can prevent network-idle from ever resolving. /// /// After navigation completes, `auth_login` explicitly waits for form selectors /// to appear before filling/clicking. pub const AUTH_LOGIN_WAIT_UNTIL: WaitUntil = WaitUntil::Load; /// Poll interval used while waiting for auth form selectors to appear. const AUTH_LOGIN_SELECTOR_POLL_INTERVAL_MS: u64 = 100; /// Time spent trying targeted username selectors before broad text-input /// fallback selectors are allowed. const AUTH_LOGIN_PREFERRED_SELECTOR_WINDOW_MS: u64 = 5_000; pub struct PendingConfirmation { pub action: String, pub cmd: Value, } /// Captured request/response metadata used to export HAR 1.2 files. pub struct HarEntry { pub request_id: String, /// Seconds since Unix epoch (CDP `wallTime`), with sub-second precision. pub wall_time: f64, // Request fields pub method: String, pub url: String, pub request_headers: Vec<(String, String)>, pub post_data: Option, pub request_body_size: i64, pub resource_type: String, // Response fields — populated by `Network.responseReceived` pub status: Option, pub status_text: String, /// Normalised from CDP `response.protocol` (e.g. `"h2"` → `"HTTP/2.0"`). pub http_version: String, pub response_headers: Vec<(String, String)>, pub mime_type: String, pub redirect_url: String, /// Updated by `Network.loadingFinished` for final accuracy. pub response_body_size: i64, /// Raw CDP `ResourceTiming` object from `Network.responseReceived`. pub cdp_timing: Option, /// Monotonic timestamp (seconds) from `Network.loadingFinished`; used to /// compute the `receive` timing phase. pub loading_finished_timestamp: Option, } pub struct RouteEntry { pub url_pattern: String, pub response: Option, pub abort: bool, /// When non-empty, only requests whose `resourceType` (as reported by /// CDP Fetch.requestPaused) is in this list are matched. Values are /// compared case-insensitively. Empty means "match any resource type". pub resource_types: Vec, } pub struct RouteResponse { pub status: Option, pub body: Option, pub content_type: Option, pub headers: Option>, } #[derive(Clone, serde::Serialize)] pub struct TrackedRequest { pub url: String, pub method: String, pub headers: Value, pub timestamp: u64, #[serde(rename = "resourceType")] pub resource_type: String, #[serde(rename = "requestId")] pub request_id: String, #[serde(rename = "postData", skip_serializing_if = "Option::is_none")] pub post_data: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(rename = "responseHeaders", skip_serializing_if = "Option::is_none")] pub response_headers: Option, #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] pub mime_type: Option, } pub struct FetchPausedRequest { pub request_id: String, pub url: String, pub resource_type: String, pub session_id: String, /// Original request headers from the Fetch.requestPaused event, needed /// because Fetch.continueRequest replaces (not merges) headers. pub request_headers: Option>, } pub enum BackendType { Cdp, WebDriver, } #[derive(Debug, Clone, Default)] pub struct PendingDialog { pub dialog_type: String, pub message: String, pub url: String, pub default_prompt: Option, } #[derive(Debug, Clone, Copy, Default)] pub struct MouseState { pub x: f64, pub y: f64, pub buttons: i32, } #[derive(Default)] struct DrainedEvents { pending_acks: Vec, new_targets: Vec, changed_targets: Vec, destroyed_targets: Vec, /// Cross-origin iframe (frame_id, session_id) pairs from Target.attachedToTarget. attached_iframe_sessions: Vec<(String, String)>, /// Session IDs from Target.detachedFromTarget. detached_iframe_sessions: Vec, } /// Compute a hash of the [`LaunchOptions`] fields that require a browser /// relaunch when changed (baked into the Chrome process at startup). /// /// Fields NOT hashed: /// ignore_https_errors, color_scheme, download_path /// /// `storage_state` is handled separately in `handle_launch()`: explicit /// `storageState` launches always require a clean local browser so the loaded /// state replaces the prior session instead of merging into it. fn launch_hash(opts: &LaunchOptions) -> u64 { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut h = DefaultHasher::new(); opts.headless.hash(&mut h); opts.extensions.hash(&mut h); opts.profile.hash(&mut h); opts.executable_path.hash(&mut h); opts.args.hash(&mut h); opts.proxy.hash(&mut h); opts.proxy_bypass.hash(&mut h); opts.proxy_username.hash(&mut h); opts.proxy_password.hash(&mut h); opts.user_agent.hash(&mut h); opts.allow_file_access.hash(&mut h); opts.hide_scrollbars.hash(&mut h); h.finish() } pub struct DaemonState { pub browser: Option, pub appium: Option, pub safari_driver: Option, pub webdriver_backend: Option, pub backend_type: BackendType, pub ref_map: RefMap, pub domain_filter: Arc>>, pub event_tracker: EventTracker, pub session_name: Option, pub session_id: String, pub tracing_state: TracingState, pub recording_state: RecordingState, event_rx: Option>, pub screencasting: bool, pub policy: Option, pub pending_confirmation: Option, pub har_recording: bool, pub har_entries: Vec, pub confirm_actions: Option, pub inspect_server: Option, pub routes: Arc>>, pub tracked_requests: Vec, pub request_tracking: bool, pub active_frame_id: Option, /// Cross-origin iframe frame_id → dedicated CDP session_id. /// Populated by Target.attachedToTarget events from Target.setAutoAttach. pub iframe_sessions: HashMap, /// Origin-scoped extra HTTP headers set via `--headers` on navigate. /// Key is the origin (scheme + host + port), value is the headers map. /// Wrapped in Arc> so the background Fetch handler can read it. pub origin_headers: Arc>>>, /// Proxy authentication credentials (username, password) for handling /// Fetch.authRequired events from authenticated proxies. pub proxy_credentials: Arc>>, /// Background task that processes Fetch.requestPaused events in real-time, /// handling domain filtering, route interception, and origin-scoped headers /// without deadlocking navigation/evaluate. fetch_handler_task: Option>, /// Background task that auto-accepts `alert` and `beforeunload` dialogs /// so they never block the agent. dialog_handler_task: Option>, pub mouse_state: MouseState, /// Tracks the currently open JavaScript dialog (alert/confirm/prompt), if any. pub pending_dialog: Option, /// When true, automatically dismiss `beforeunload` dialogs and accept `alert` /// dialogs so they never block the agent. Enabled by default. pub auto_dialog: bool, /// Shared slot for stream server to receive CDP client when browser launches. pub stream_client: Option>>>>, /// Stream server instance kept alive so the broadcast channel remains open. pub stream_server: Option>, /// Hash of launch options used for the current browser, for relaunch detection. launch_hash: Option, /// Browser engine name (e.g. "chrome", "lightpanda") for observability. pub engine: String, /// Default timeout for wait operations, from AGENT_BROWSER_DEFAULT_TIMEOUT env var. pub default_timeout_ms: u64, /// Last viewport settings (width, height, deviceScaleFactor, mobile), /// re-applied to new contexts (e.g., recording). pub viewport: Option<(i32, i32, f64, bool)>, } impl DaemonState { pub fn new() -> Self { Self { browser: None, appium: None, safari_driver: None, webdriver_backend: None, backend_type: BackendType::Cdp, ref_map: RefMap::new(), domain_filter: Arc::new(RwLock::new( env::var("AGENT_BROWSER_ALLOWED_DOMAINS") .ok() .filter(|s| !s.is_empty()) .map(|s| DomainFilter::new(&s)), )), event_tracker: EventTracker::new(), session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(), session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), tracing_state: TracingState::new(), recording_state: RecordingState::new(), event_rx: None, screencasting: false, policy: ActionPolicy::load_if_exists(), pending_confirmation: None, har_recording: false, har_entries: Vec::new(), confirm_actions: ConfirmActions::from_env(), inspect_server: None, routes: Arc::new(RwLock::new(Vec::new())), tracked_requests: Vec::new(), request_tracking: false, active_frame_id: None, iframe_sessions: HashMap::new(), origin_headers: Arc::new(RwLock::new(HashMap::new())), proxy_credentials: Arc::new(RwLock::new(None)), fetch_handler_task: None, dialog_handler_task: None, mouse_state: MouseState::default(), pending_dialog: None, auto_dialog: !matches!( env::var("AGENT_BROWSER_NO_AUTO_DIALOG").as_deref(), Ok("1" | "true" | "yes") ), stream_client: None, stream_server: None, launch_hash: None, engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()), default_timeout_ms: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(30_000), viewport: None, } } /// Extract the timeout from a command JSON, falling back to the /// configured `default_timeout_ms` (from `AGENT_BROWSER_DEFAULT_TIMEOUT`). /// All wait-family handlers should use this instead of reading the /// timeout field and providing their own fallback. fn timeout_ms(&self, cmd: &Value) -> u64 { cmd.get("timeout") .and_then(|v| v.as_u64()) .unwrap_or(self.default_timeout_ms) } fn reset_input_state(&mut self) { self.mouse_state = MouseState::default(); } /// Create state with an optional stream client slot and server instance /// (for daemon startup with stream server). pub fn new_with_stream( stream_client: Option>>>>, stream_server: Option>, ) -> Self { let mut s = Self::new(); if stream_server.is_some() { s.request_tracking = true; } s.stream_client = stream_client; s.stream_server = stream_server; s } fn subscribe_to_browser_events(&mut self) { if let Some(ref browser) = self.browser { self.event_rx = Some(browser.client.subscribe()); } } /// Start the background task that processes Fetch.requestPaused and /// Fetch.authRequired events in real-time (domain filtering, route /// interception, origin-scoped headers, proxy authentication). /// Must be called after the browser is set and events are subscribed. fn start_fetch_handler(&mut self) { // Abort any existing handler. if let Some(task) = self.fetch_handler_task.take() { task.abort(); } let Some(ref browser) = self.browser else { return; }; let client = browser.client.clone(); let mut rx = browser.client.subscribe(); let domain_filter = self.domain_filter.clone(); let routes = self.routes.clone(); let origin_headers = self.origin_headers.clone(); let proxy_credentials = self.proxy_credentials.clone(); self.fetch_handler_task = Some(tokio::spawn(async move { loop { match rx.recv().await { Ok(event) if event.method == "Fetch.authRequired" => { let request_id = event .params .get("requestId") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let sid = event.session_id.clone().unwrap_or_default(); let creds = proxy_credentials.read().await; if let Some((ref user, ref pass)) = *creds { let _ = client .send_command( "Fetch.continueWithAuth", Some(json!({ "requestId": request_id, "authChallengeResponse": { "response": "ProvideCredentials", "username": user, "password": pass, } })), Some(&sid), ) .await; } else { let _ = client .send_command( "Fetch.continueWithAuth", Some(json!({ "requestId": request_id, "authChallengeResponse": { "response": "CancelAuth", } })), Some(&sid), ) .await; } } Ok(event) if event.method == "Fetch.requestPaused" => { let request_id = event .params .get("requestId") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let request_url = event .params .get("request") .and_then(|r| r.get("url")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let resource_type = event .params .get("resourceType") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let request_headers = event .params .get("request") .and_then(|r| r.get("headers")) .and_then(|h| h.as_object()) .cloned(); let sid = event.session_id.clone().unwrap_or_default(); let paused = FetchPausedRequest { request_id, url: request_url, resource_type, session_id: sid, request_headers, }; let df = domain_filter.read().await; let rt = routes.read().await; let oh = origin_headers.read().await; resolve_fetch_paused(&client, df.as_ref(), &rt, &oh, &paused).await; } Ok(_) => continue, Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(_) => break, } } })); } /// Start the background task that auto-accepts `alert` and `beforeunload` /// dialogs so they never block the agent. `confirm` and `prompt` dialogs /// are left for the agent to handle explicitly. fn start_dialog_handler(&mut self) { if let Some(task) = self.dialog_handler_task.take() { task.abort(); } if !self.auto_dialog { return; } let Some(ref browser) = self.browser else { return; }; let client = browser.client.clone(); let mut rx = browser.client.subscribe(); self.dialog_handler_task = Some(tokio::spawn(async move { loop { match rx.recv().await { Ok(event) if event.method == "Page.javascriptDialogOpening" => { let dialog_type = event .params .get("type") .and_then(|v| v.as_str()) .unwrap_or(""); if matches!(dialog_type, "beforeunload" | "alert") { let message = event .params .get("message") .and_then(|v| v.as_str()) .unwrap_or(""); eprintln!("[auto-dismiss] {} dialog: {}", dialog_type, message); let sid = event.session_id.clone().unwrap_or_default(); if let Err(e) = client .send_command( "Page.handleJavaScriptDialog", Some(json!({ "accept": true })), Some(&sid), ) .await { eprintln!( "[auto-dismiss] failed to dismiss {} dialog: {}", dialog_type, e ); } } } Ok(_) => continue, Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(_) => break, } } })); } /// Update the stream server's CDP client slot when browser is set or cleared. pub async fn update_stream_client(&self) { if let Some(ref slot) = self.stream_client { let mut guard = slot.write().await; *guard = self.browser.as_ref().map(|m| Arc::clone(&m.client)); } if let Some(ref server) = self.stream_server { // Update the CDP page session ID so screencast commands target the right page let session_id = self .browser .as_ref() .and_then(|m| m.active_session_id().ok().map(|s| s.to_string())); server.set_cdp_session_id(session_id).await; // Broadcast connection status change to WebSocket clients let connected = self.browser.is_some(); let sc = server.is_screencasting().await; let (vw, vh) = server.viewport().await; server .broadcast_status(connected, sc, vw, vh, &self.engine) .await; if let Some(ref mgr) = self.browser { server.broadcast_tabs(&mgr.tab_list()).await; } else { server.broadcast_tabs(&[]).await; } // Notify the background CDP event loop that the client changed server.notify_client_changed(); } } /// Spawn a background task that polls screenshots and pipes them to ffmpeg. async fn start_recording_task( &mut self, client: Arc, session_id: String, ) -> Result<(), String> { let shared_count = Arc::new(AtomicU64::new(0)); let (cancel_tx, cancel_rx) = oneshot::channel(); let handle = recording::spawn_recording_task( client, session_id, self.recording_state.output_path.clone(), shared_count.clone(), cancel_rx, ); self.recording_state.capture_task = Some(handle); self.recording_state.shared_frame_count = Some(shared_count); self.recording_state.cancel_tx = Some(cancel_tx); Ok(()) } async fn stop_recording_task(&mut self) -> Result<(), String> { recording::stop_recording_task(&mut self.recording_state).await } pub async fn drain_cdp_events_background(&mut self) { let drained = self.drain_cdp_events(); self.apply_drained_events(drained).await; } async fn apply_drained_events(&mut self, drained: DrainedEvents) { // ACK screencast frames if !drained.pending_acks.is_empty() { if let Some(ref browser) = self.browser { if let Ok(session_id) = browser.active_session_id() { for ack_sid in drained.pending_acks { let _ = stream::ack_screencast_frame(&browser.client, session_id, ack_sid) .await; } } } } // Remove destroyed targets for target_id in &drained.destroyed_targets { if let Some(ref mut mgr) = self.browser { mgr.remove_page_by_target_id(target_id); } } // Track cross-origin iframe sessions for (frame_id, iframe_sid) in &drained.attached_iframe_sessions { self.iframe_sessions .insert(frame_id.clone(), iframe_sid.clone()); if let Some(ref mgr) = self.browser { let _ = mgr .client .send_command_no_params( "Runtime.runIfWaitingForDebugger", Some(iframe_sid.as_str()), ) .await; let _ = mgr .client .send_command_no_params("DOM.enable", Some(iframe_sid.as_str())) .await; let _ = mgr .client .send_command_no_params("Accessibility.enable", Some(iframe_sid.as_str())) .await; if self.har_recording || self.request_tracking { let _ = mgr .client .send_command_no_params("Network.enable", Some(iframe_sid.as_str())) .await; } // Hide automation markers in this cross-origin iframe session too. apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await; } } for sid in &drained.detached_iframe_sessions { self.iframe_sessions.retain(|_, v| v != sid); } // Attach and register new targets for te in &drained.new_targets { if let Some(ref mut mgr) = self.browser { let attach_result: Result = mgr .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: te.target_info.target_id.clone(), flatten: true, }, None, ) .await; if let Ok(attach) = attach_result { let _ = mgr.enable_domains_pub(&attach.session_id).await; // Install domain filter on new pages let df = self.domain_filter.read().await; if let Some(ref filter) = *df { let has_proxy_creds = self.proxy_credentials.read().await.is_some(); let _ = network::install_domain_filter( &mgr.client, &attach.session_id, &filter.allowed_domains, has_proxy_creds, ) .await; } let tab_id = mgr.assign_tab_id(); // Passively discovered (event-driven) — must NOT steal the // active tab, or a foreign/user/other-session tab opening // hijacks this session's eval/screenshot target. mgr.add_background_page(super::browser::PageInfo { tab_id, label: None, target_id: te.target_info.target_id.clone(), session_id: attach.session_id, url: te.target_info.url.clone(), title: te.target_info.title.clone(), target_type: te.target_info.target_type.clone(), }); } } } // Update changed targets for te in &drained.changed_targets { if let Some(ref mut mgr) = self.browser { mgr.update_page_target_info(&te.target_info); } } } fn drain_cdp_events(&mut self) -> DrainedEvents { let rx = match self.event_rx.as_mut() { Some(rx) => rx, None => return DrainedEvents::default(), }; let mut pending_acks: Vec = Vec::new(); let mut new_targets: Vec = Vec::new(); let mut new_target_ids: HashSet = HashSet::new(); let mut changed_targets: Vec = Vec::new(); let mut destroyed_targets: Vec = Vec::new(); let mut attached_iframe_sessions: Vec<(String, String)> = Vec::new(); let mut detached_iframe_sessions: Vec = Vec::new(); loop { match rx.try_recv() { Ok(event) => { // Target events are not session-scoped; handle them first match event.method.as_str() { "Target.targetCreated" => { if let Ok(te) = serde_json::from_value::(event.params.clone()) { if should_track_target(&te.target_info) { let already_tracked = self .browser .as_ref() .is_none_or(|b| b.has_target(&te.target_info.target_id)); if !already_tracked { new_target_ids.insert(te.target_info.target_id.clone()); new_targets.push(te); } } } continue; } "Target.targetInfoChanged" => { if let Ok(te) = serde_json::from_value::( event.params.clone(), ) { if should_track_target(&te.target_info) { // If this target is not yet tracked (e.g. it was // initially filtered because its URL was // chrome://newtab/), promote it to a new target // so it gets attached and added to `pages`. let already_tracked = self .browser .as_ref() .is_some_and(|b| b.has_target(&te.target_info.target_id)); if already_tracked || new_target_ids.contains(&te.target_info.target_id) { changed_targets.push(te); } else { new_target_ids.insert(te.target_info.target_id.clone()); new_targets.push(TargetCreatedEvent { target_info: te.target_info, }); } } } continue; } "Target.targetDestroyed" => { if let Ok(te) = serde_json::from_value::(event.params.clone()) { destroyed_targets.push(te.target_id); } continue; } "Target.attachedToTarget" => { if let (Some(sid), Some(target_info)) = ( event.params.get("sessionId").and_then(|v| v.as_str()), event.params.get("targetInfo"), ) { let target_type = target_info .get("type") .and_then(|v| v.as_str()) .unwrap_or(""); if target_type == "iframe" { // For OOPIF targets, Chrome uses the frameId as // the targetId, so we can key iframe_sessions by it. if let Some(target_id) = target_info.get("targetId").and_then(|v| v.as_str()) { attached_iframe_sessions .push((target_id.to_string(), sid.to_string())); } } } continue; } "Target.detachedFromTarget" => { if let Some(sid) = event.params.get("sessionId").and_then(|v| v.as_str()) { detached_iframe_sessions.push(sid.to_string()); } continue; } _ => {} } let session_matches = if let Some(ref browser) = self.browser { event.session_id.as_deref() == browser.active_session_id().ok() } else { false }; // Allow Network events from cross-origin iframe sessions // when HAR recording or request tracking is active. let iframe_network_event = !session_matches && (self.har_recording || self.request_tracking) && event.method.starts_with("Network.") && event .session_id .as_ref() .is_some_and(|sid| self.iframe_sessions.values().any(|v| v == sid)); if !session_matches && !iframe_network_event { continue; } match event.method.as_str() { "Runtime.consoleAPICalled" => { let level = event .params .get("type") .and_then(|v| v.as_str()) .unwrap_or("log"); let raw_args: Vec = event .params .get("args") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let text = network::format_console_args(&raw_args); if let Some(ref server) = self.stream_server { server.broadcast_console(level, &text, &raw_args); } self.event_tracker.add_console(level, &text, raw_args); } "Runtime.exceptionThrown" => { if let Ok(ex_event) = serde_json::from_value::(event.params.clone()) { let details = &ex_event.exception_details; let text = details .exception .as_ref() .and_then(|e| e.description.as_deref()) .unwrap_or(&details.text); self.event_tracker.add_error( text, None, details.line_number, details.column_number, ); if let Some(ref server) = self.stream_server { server.broadcast_page_error( text, details.line_number, details.column_number, ); } } } "Network.requestWillBeSent" if self.har_recording || self.request_tracking => { if let Some(request) = event.params.get("request") { let method = request .get("method") .and_then(|v| v.as_str()) .unwrap_or("GET") .to_string(); let url = request .get("url") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let request_id = event .params .get("requestId") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if self.har_recording { let wall_time = event .params .get("wallTime") .and_then(|v| v.as_f64()) .unwrap_or(0.0); let request_headers = har_extract_headers(request.get("headers")); let post_data = request .get("postData") .and_then(|v| v.as_str()) .map(String::from); let request_body_size = post_data.as_ref().map(|s| s.len() as i64).unwrap_or(0); let resource_type = event .params .get("type") .and_then(|v| v.as_str()) .unwrap_or("Other") .to_string(); self.har_entries.push(HarEntry { request_id: request_id.clone(), wall_time, method: method.clone(), url: url.clone(), request_headers, post_data, request_body_size, resource_type, status: None, status_text: String::new(), http_version: "HTTP/1.1".to_string(), response_headers: Vec::new(), mime_type: String::new(), redirect_url: String::new(), response_body_size: -1, cdp_timing: None, loading_finished_timestamp: None, }); } if self.request_tracking { let headers = request.get("headers").cloned().unwrap_or(json!({})); let resource_type = event .params .get("type") .and_then(|v| v.as_str()) .unwrap_or("Other") .to_string(); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); self.tracked_requests.push(TrackedRequest { url, method, headers, timestamp, resource_type, request_id, post_data: request .get("postData") .and_then(|v| v.as_str()) .map(String::from), status: None, response_headers: None, mime_type: None, }); } } } "Network.responseReceived" if self.har_recording || self.request_tracking => { if let Some(response) = event.params.get("response") { let request_id = event .params .get("requestId") .and_then(|v| v.as_str()) .unwrap_or(""); let status = response.get("status").and_then(|v| v.as_i64()); let status_text = response .get("statusText") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let mime_type = response .get("mimeType") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let http_version = response .get("protocol") .and_then(|v| v.as_str()) .map(har_cdp_protocol_to_http_version) .unwrap_or_else(|| "HTTP/1.1".to_string()); let response_headers = har_extract_headers(response.get("headers")); let redirect_url = response_headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case("location")) .map(|(_, v)| v.clone()) .unwrap_or_default(); let encoded_data_length = response .get("encodedDataLength") .and_then(|v| v.as_i64()) .unwrap_or(-1); let cdp_timing = response.get("timing").cloned(); if self.har_recording { if let Some(entry) = self .har_entries .iter_mut() .rev() .find(|e| e.request_id == request_id) { entry.status = status; entry.status_text = status_text; entry.mime_type = mime_type; entry.http_version = http_version; entry.response_headers = response_headers; entry.redirect_url = redirect_url; entry.response_body_size = encoded_data_length; entry.cdp_timing = cdp_timing; } } if self.request_tracking { let resp_headers = response.get("headers").cloned(); let resp_mime = response .get("mimeType") .and_then(|v| v.as_str()) .map(String::from); if let Some(entry) = self .tracked_requests .iter_mut() .rev() .find(|e| e.request_id == request_id) { entry.status = status; entry.mime_type = resp_mime; entry.response_headers = resp_headers; } } } } "Network.loadingFinished" if self.har_recording => { let request_id = event .params .get("requestId") .and_then(|v| v.as_str()) .unwrap_or(""); let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64()); let encoded_data_length = event .params .get("encodedDataLength") .and_then(|v| v.as_i64()); if let Some(entry) = self .har_entries .iter_mut() .rev() .find(|e| e.request_id == request_id) { if let Some(ts) = timestamp { entry.loading_finished_timestamp = Some(ts); } if let Some(len) = encoded_data_length { entry.response_body_size = len; } } } "Network.loadingFailed" if self.har_recording => { let request_id = event .params .get("requestId") .and_then(|v| v.as_str()) .unwrap_or(""); let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64()); let error_text = event .params .get("errorText") .and_then(|v| v.as_str()) .unwrap_or("Failed"); if let Some(entry) = self .har_entries .iter_mut() .rev() .find(|e| e.request_id == request_id) { if entry.status.is_none() { entry.status = Some(0); entry.status_text = error_text.to_string(); } if let Some(ts) = timestamp { entry.loading_finished_timestamp = Some(ts); } } } // Frame broadcasting and acks are handled in real-time by the // stream server's background CDP event loop. Here we just // collect acks as a fallback for non-streaming mode. "Page.screencastFrame" if self.stream_server.is_none() => { if let Some(sid) = event.params.get("sessionId").and_then(|v| v.as_i64()) { pending_acks.push(sid); } } "Page.javascriptDialogOpening" => { if let Ok(dialog_event) = serde_json::from_value::( event.params.clone(), ) { // When auto_dialog is enabled, alert and beforeunload // dialogs are handled by the background dialog_handler_task. // Skip tracking them to avoid a stale warning. let auto_handled = self.auto_dialog && matches!( dialog_event.dialog_type.as_str(), "beforeunload" | "alert" ); if !auto_handled { self.pending_dialog = Some(PendingDialog { dialog_type: dialog_event.dialog_type, message: dialog_event.message, url: dialog_event.url, default_prompt: dialog_event.default_prompt, }); } } } "Page.javascriptDialogClosed" => { self.pending_dialog = None; } // Fetch.requestPaused is handled by the background // fetch_handler_task — no need to collect here. _ => {} } } Err(broadcast::error::TryRecvError::Empty) => break, Err(broadcast::error::TryRecvError::Lagged(n)) => { eprintln!("[agent-browser] Warning: CDP event buffer overflowed, {} events dropped. Network requests may be missing from HAR output.", n); continue; } Err(broadcast::error::TryRecvError::Closed) => { self.event_rx = None; break; } } } DrainedEvents { pending_acks, new_targets, changed_targets, destroyed_targets, attached_iframe_sessions, detached_iframe_sessions, } } } impl Drop for DaemonState { fn drop(&mut self) { // The background fetch handler sits in rx.recv().await indefinitely. // Without aborting it, the tokio runtime won't shut down (tests hang). if let Some(task) = self.fetch_handler_task.take() { task.abort(); } if let Some(task) = self.dialog_handler_task.take() { task.abort(); } } } pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or(""); // Apply per-invocation overrides the client forwarded (the daemon's own env // is frozen at spawn). CLICK_MODE is read fresh from the process env by // interaction::click, so mirror it here — set when this command provided it, // clear otherwise, so a value from an earlier command never leaks forward. match cmd.get("_clickMode").and_then(|v| v.as_str()) { Some(m) if !m.is_empty() => std::env::set_var("AGENT_BROWSER_CLICK_MODE", m), _ => std::env::remove_var("AGENT_BROWSER_CLICK_MODE"), } // Humanize: set the session level from the client's --humanize / env. Only // set when provided (don't clear — the adaptive per-navigation detector also // owns this level between explicit overrides). if let Some(h) = cmd.get("_humanize").and_then(|v| v.as_str()) { if let Some(level) = super::humanize::HumanizeLevel::parse(h) { super::humanize::set_detected_level(level); } } let id = cmd .get("id") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let cmd_start = std::time::Instant::now(); if let Some(ref server) = state.stream_server { server.broadcast_command(action, &id, cmd); } // Drain and apply pending CDP events (console, errors, screencast frames, target lifecycle) state.drain_cdp_events_background().await; // Hot-reload and check action policy if let Some(ref mut policy) = state.policy { let _ = policy.reload(); match policy.check(action) { PolicyResult::Allow => {} PolicyResult::Deny(reason) => { return error_response( &id, &format!("Action '{}' denied by policy: {}", action, reason), ); } PolicyResult::RequiresConfirmation => { state.pending_confirmation = Some(PendingConfirmation { action: action.to_string(), cmd: cmd.clone(), }); return json!({ "id": id, "success": true, "data": { "confirmation_required": true, "action": action }, }); } } } // Check AGENT_BROWSER_CONFIRM_ACTIONS (category-based, independent of policy file) if action != "confirm" && action != "deny" { if let Some(ref ca) = state.confirm_actions { if ca.requires_confirmation(action) { state.pending_confirmation = Some(PendingConfirmation { action: action.to_string(), cmd: cmd.clone(), }); return json!({ "id": id, "success": true, "data": { "confirmation_required": true, "confirmation_id": id, "action": action, }, }); } } } let skip_launch = matches!( action, "" | "launch" | "close" | "har_stop" | "credentials_set" | "credentials_get" | "credentials_delete" | "credentials_list" | "auth_save" | "auth_show" | "auth_delete" | "auth_list" | "state_list" | "state_show" | "state_clear" | "state_clean" | "state_rename" | "device_list" | "stream_enable" | "stream_disable" | "stream_status" ); if !skip_launch { // Check if existing connection is stale and needs re-launch. // First do a fast, non-blocking check: did the browser process crash/exit? // This avoids a 3-second CDP timeout when Chrome is already dead. let needs_launch = if let Some(ref mut mgr) = state.browser { mgr.has_process_exited() || !mgr.is_connection_alive().await } else { true }; if needs_launch { if state.browser.is_some() { if let Some(ref mut mgr) = state.browser { let _ = mgr.close().await; } state.browser = None; state.screencasting = false; state.reset_input_state(); state.update_stream_client().await; } if let Err(e) = auto_launch(state).await { return error_response(&id, &format!("Auto-launch failed: {}", e)); } } if let Some(ref mut mgr) = state.browser { if mgr.page_count() == 0 { let _ = mgr.ensure_page().await; } } } // WebDriver backend: reject unsupported CDP-only actions if matches!(state.backend_type, BackendType::WebDriver) && WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) { return error_response( &id, &format!( "Action '{}' is not supported on the WebDriver backend", action ), ); } let result = match action { "launch" => handle_launch(cmd, state).await, "navigate" => handle_navigate(cmd, state).await, "url" => handle_url(state).await, "cdp_url" => handle_cdp_url(state), "inspect" => handle_inspect(state).await, "title" => handle_title(state).await, "content" => handle_content(state).await, "evaluate" => handle_evaluate(cmd, state).await, "close" => handle_close(state).await, "stealth_status" => handle_stealth_status(state).await, "snapshot" => handle_snapshot(cmd, state).await, "screenshot" => handle_screenshot(cmd, state).await, "click" => handle_click(cmd, state).await, "dblclick" => handle_dblclick(cmd, state).await, "fill" => handle_fill(cmd, state).await, "type" => handle_type(cmd, state).await, "press" => handle_press(cmd, state).await, "pick" => handle_pick(cmd, state).await, "hover" => handle_hover(cmd, state).await, "scroll" => handle_scroll(cmd, state).await, "select" => handle_select(cmd, state).await, "check" => handle_check(cmd, state).await, "uncheck" => handle_uncheck(cmd, state).await, "wait" => handle_wait(cmd, state).await, "gettext" => handle_gettext(cmd, state).await, "getattribute" => handle_getattribute(cmd, state).await, "isvisible" => handle_isvisible(cmd, state).await, "isenabled" => handle_isenabled(cmd, state).await, "ischecked" => handle_ischecked(cmd, state).await, "back" => handle_back(state).await, "forward" => handle_forward(state).await, "reload" => handle_reload(state).await, "cookies_get" => handle_cookies_get(cmd, state).await, "cookies_set" => handle_cookies_set(cmd, state).await, "cookies_clear" => handle_cookies_clear(state).await, "storage_get" => handle_storage_get(cmd, state).await, "storage_set" => handle_storage_set(cmd, state).await, "storage_clear" => handle_storage_clear(cmd, state).await, "setcontent" => handle_setcontent(cmd, state).await, "headers" => handle_headers(cmd, state).await, "offline" => handle_offline(cmd, state).await, "console" => handle_console(cmd, state).await, "errors" => handle_errors(state).await, "state_save" => handle_state_save(cmd, state).await, "state_load" => handle_state_load(cmd, state).await, "state_list" | "state_show" | "state_clear" | "state_clean" | "state_rename" => { state::dispatch_state_command(cmd) .expect("dispatch_state_command must handle all state_* actions matched here") } "trace_start" => handle_trace_start(state).await, "trace_stop" => handle_trace_stop(cmd, state).await, "profiler_start" => handle_profiler_start(cmd, state).await, "profiler_stop" => handle_profiler_stop(cmd, state).await, "recording_start" => handle_recording_start(cmd, state).await, "recording_stop" => handle_recording_stop(state).await, "recording_restart" => handle_recording_restart(cmd, state).await, "pdf" => handle_pdf(cmd, state).await, "tab_list" => handle_tab_list(state).await, "tab_new" => handle_tab_new(cmd, state).await, "tab_switch" => handle_tab_switch(cmd, state).await, "tab_close" => handle_tab_close(cmd, state).await, "viewport" => handle_viewport(cmd, state).await, "useragent" | "user_agent" => handle_user_agent(cmd, state).await, "set_media" => handle_set_media(cmd, state).await, "download" => handle_download(cmd, state).await, "diff_snapshot" => handle_diff_snapshot(cmd, state).await, "diff_url" => handle_diff_url(cmd, state).await, "credentials_set" => handle_credentials_set(cmd).await, "credentials_get" => handle_credentials_get(cmd).await, "credentials_delete" => handle_credentials_delete(cmd).await, "credentials_list" => handle_credentials_list().await, "mouse" => handle_mouse(cmd, state).await, "keyboard" => handle_keyboard(cmd, state).await, "focus" => handle_focus(cmd, state).await, "clear" => handle_clear(cmd, state).await, "selectall" => handle_selectall(cmd, state).await, "scrollintoview" => handle_scrollintoview(cmd, state).await, "dispatch" => handle_dispatch(cmd, state).await, "highlight" => handle_highlight(cmd, state).await, "tap" => handle_tap(cmd, state).await, "boundingbox" => handle_boundingbox(cmd, state).await, "innertext" => handle_innertext(cmd, state).await, "innerhtml" => handle_innerhtml(cmd, state).await, "inputvalue" => handle_inputvalue(cmd, state).await, "setvalue" => handle_setvalue(cmd, state).await, "count" => handle_count(cmd, state).await, "styles" => handle_styles(cmd, state).await, "bringtofront" => handle_bringtofront(state).await, "timezone" => handle_timezone(cmd, state).await, "locale" => handle_locale(cmd, state).await, "geolocation" => handle_geolocation(cmd, state).await, "permissions" => handle_permissions(cmd, state).await, "dialog" => handle_dialog(cmd, state).await, "upload" => handle_upload(cmd, state).await, "addscript" => handle_addscript(cmd, state).await, "addinitscript" => handle_addinitscript(cmd, state).await, "removeinitscript" => handle_removeinitscript(cmd, state).await, "addstyle" => handle_addstyle(cmd, state).await, "react_tree" => handle_react_tree(cmd, state).await, "react_inspect" => handle_react_inspect(cmd, state).await, "react_renders_start" => handle_react_renders_start(cmd, state).await, "react_renders_stop" => handle_react_renders_stop(cmd, state).await, "react_suspense" => handle_react_suspense(cmd, state).await, "vitals" => handle_vitals(cmd, state).await, "pushstate" => handle_pushstate(cmd, state).await, "clipboard" => handle_clipboard(cmd, state).await, "wheel" => handle_wheel(cmd, state).await, "device" => handle_device(cmd, state).await, "screencast_start" => handle_screencast_start(cmd, state).await, "screencast_stop" => handle_screencast_stop(state).await, "stream_enable" => handle_stream_enable(cmd, state).await, "stream_disable" => handle_stream_disable(state).await, "stream_status" => handle_stream_status(state).await, "waitforurl" => handle_waitforurl(cmd, state).await, "waitforloadstate" => handle_waitforloadstate(cmd, state).await, "waitforfunction" => handle_waitforfunction(cmd, state).await, "frame" => handle_frame(cmd, state).await, "mainframe" => handle_mainframe(state).await, "getbyrole" => handle_getbyrole(cmd, state).await, "getbytext" => handle_getbytext(cmd, state).await, "getbylabel" => handle_getbylabel(cmd, state).await, "getbyplaceholder" => handle_getbyplaceholder(cmd, state).await, "getbyalttext" => handle_getbyalttext(cmd, state).await, "getbytitle" => handle_getbytitle(cmd, state).await, "getbytestid" => handle_getbytestid(cmd, state).await, "nth" => handle_nth(cmd, state).await, "find" => handle_find(cmd, state).await, "evalhandle" => handle_evalhandle(cmd, state).await, "drag" => handle_drag(cmd, state).await, "expose" => handle_expose(cmd, state).await, "pause" => handle_pause(state).await, "multiselect" => handle_multiselect(cmd, state).await, "responsebody" => handle_responsebody(cmd, state).await, "waitfordownload" => handle_waitfordownload(cmd, state).await, "window_new" => handle_window_new(cmd, state).await, "diff_screenshot" => handle_diff_screenshot(cmd, state).await, "video_start" => handle_video_start(cmd, state).await, "video_stop" => handle_video_stop(state).await, "har_start" => handle_har_start(state).await, "har_stop" => handle_har_stop(cmd, state).await, "route" => handle_route(cmd, state).await, "unroute" => handle_unroute(cmd, state).await, "requests" => handle_requests(cmd, state).await, "request_detail" => handle_request_detail(cmd, state).await, "credentials" => handle_http_credentials(cmd, state).await, "emulatemedia" => handle_set_media(cmd, state).await, "auth_save" => handle_auth_save(cmd).await, "auth_login" => handle_auth_login(cmd, state).await, "auth_list" => handle_credentials_list().await, "auth_delete" => handle_credentials_delete(cmd).await, "auth_show" => handle_auth_show(cmd).await, "confirm" => handle_confirm(cmd, state).await, "deny" => handle_deny(cmd, state).await, "swipe" => handle_swipe(cmd, state).await, "device_list" => handle_device_list().await, "input_mouse" => handle_input_mouse(cmd, state).await, "input_keyboard" => handle_input_keyboard(cmd, state).await, "input_touch" => handle_input_touch(cmd, state).await, "keydown" => handle_keydown(cmd, state).await, "keyup" => handle_keyup(cmd, state).await, "inserttext" => handle_inserttext(cmd, state).await, "mousemove" => handle_mousemove(cmd, state).await, "mousedown" => handle_mousedown(cmd, state).await, "mouseup" => handle_mouseup(cmd, state).await, _ => Err(format!("Not yet implemented: {}", action)), }; let mut resp = match result { Ok(data) => success_response(&id, data), Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)), }; // Auto-report pending JavaScript dialog so agents know why commands may hang if action != "dialog" { if let Some(ref dialog) = state.pending_dialog { if let Some(obj) = resp.as_object_mut() { obj.insert( "warning".to_string(), json!(format!( "A JavaScript {} dialog is blocking the page: \"{}\" — use `dialog accept` or `dialog dismiss` to resolve it", dialog.dialog_type, dialog.message )), ); } } } if let Some(ref server) = state.stream_server { let duration_ms = cmd_start.elapsed().as_millis() as u64; let success = resp .get("status") .and_then(|v| v.as_str()) .is_some_and(|s| s == "success"); let data = resp.get("data").cloned().unwrap_or(Value::Null); server.broadcast_result(&id, action, success, &data, duration_ms); if let Some(ref mgr) = state.browser { server.broadcast_tabs(&mgr.tab_list()).await; // Keep the stream server's CDP session in sync with the active tab // so screencasting always targets the correct page. if matches!( action, "tab_new" | "tab_switch" | "tab_close" | "open" | "navigate" ) { let session_id = mgr.active_session_id().ok().map(|s| s.to_string()); server.set_cdp_session_id(session_id).await; server.notify_client_changed(); } } } resp } // --------------------------------------------------------------------------- // Auto-launch // --------------------------------------------------------------------------- /// Connect to a running Chrome via auto-discovery and open a fresh tab so /// subsequent navigations don't hijack the user's existing tabs. async fn connect_auto_with_fresh_tab() -> Result { let mut mgr = BrowserManager::connect_auto().await?; // tab_new creates the tab in the background (CreateTargetParams.background), // so attaching to the user's Chrome never steals their foreground tab. We // deliberately do NOT bring it to front — silent operation. mgr.tab_new(None, None).await?; let session_id = mgr.active_session_id()?.to_string(); // 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) } async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { let mut options = launch_options_from_env(); // Use the stream server's viewport dimensions for --window-size so the // content area matches the desired viewport from the start. if let Some(ref server) = state.stream_server { options.viewport_size = Some(server.viewport().await); } let engine = env::var("AGENT_BROWSER_ENGINE").ok(); // Extract storage_state before options is moved into BrowserManager::launch. let storage_state_path = options.storage_state.clone(); // Store proxy credentials for Fetch.authRequired handling let has_proxy_auth = options.proxy_username.is_some(); if has_proxy_auth { let mut creds = state.proxy_credentials.write().await; *creds = Some(( options.proxy_username.clone().unwrap_or_default(), options.proxy_password.clone().unwrap_or_default(), )); } state.engine = engine.as_deref().unwrap_or("chrome").to_string(); write_engine_file(&state.session_id, &state.engine); write_extensions_file(&state.session_id); if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") { let mgr = BrowserManager::connect_cdp(&cdp).await?; state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; apply_launch_init_scripts(state).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; apply_stealth_to_browser(state).await; try_restore_navigation(state).await; return Ok(()); } let force_launch = env::var("AGENT_BROWSER_FORCE_LAUNCH").is_ok(); // Default behavior: try to connect to the user's existing Chrome first. // This shares cookies/sessions so the agent can reuse logged-in state. // Skip if --launch/--new was passed or running in CI. if env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok() && !force_launch { match connect_auto_with_fresh_tab().await { Ok(mgr) => { state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; apply_launch_init_scripts(state).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; apply_stealth_to_browser(state).await; try_restore_navigation(state).await; return Ok(()); } Err(_e) => { // Could not find a running Chrome with CDP enabled. // Return a helpful error guiding the user to enable it. return Err(format!( "Could not connect to your Chrome browser.\n\n\ If Chrome showed an \"Allow remote debugging?\" dialog, click \ Allow and re-run — that consent is what lets agent-browser attach.\n\n\ Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\ {}\n\n\ Or launch a separate browser that KEEPS your login state:\n \ agent-browser --launch --profile auto open \n\ (plain `--launch` alone uses a temporary EMPTY profile — no cookies, \ no logged-in sessions.)\n\n\ Note: remote debugging is a startup flag, not a Chrome setting — \ chrome://inspect/#remote-debugging only enables target discovery and \ does NOT expose the CDP HTTP API on /json/version. \ A full restart with --remote-debugging-port= is required.", chrome_relaunch_hint(), )); } } } // Cloud provider: when AGENT_BROWSER_PROVIDER is set, connect via the // provider API instead of launching a local Chrome instance. This mirrors // the logic in handle_launch() so that auto_launch (triggered by any // command arriving before an explicit "launch") honours the provider env. if let Ok(provider) = env::var("AGENT_BROWSER_PROVIDER") { let p = provider.to_lowercase(); // ios/safari are device providers handled via explicit launch command if !p.is_empty() && p != "ios" && p != "safari" { let conn = providers::connect_provider(&p).await?; let ws_headers = if p == "agentcore" { providers::take_agentcore_ws_headers() } else { None }; let connect_result = if conn.direct_page { BrowserManager::connect_cdp_direct(&conn.ws_url).await } else if ws_headers.is_some() { BrowserManager::connect_cdp_with_headers(&conn.ws_url, ws_headers).await } else { BrowserManager::connect_cdp(&conn.ws_url).await }; match connect_result { Ok(mgr) => { state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; write_provider_file(&state.session_id, &p); apply_launch_init_scripts(state).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; return Ok(()); } Err(e) => { if let Some(ref ps) = conn.session { providers::close_provider_session(ps).await; } return Err(format!("Provider '{}' connection failed: {}", p, e)); } } } } let hash = launch_hash(&options); let mgr = BrowserManager::launch(options, engine.as_deref()).await?; state.reset_input_state(); state.browser = Some(mgr); state.launch_hash = Some(hash); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; // Enable Fetch with handleAuthRequests for proxy authentication if has_proxy_auth { if let Some(ref mgr) = state.browser { if let Ok(session_id) = mgr.active_session_id() { let _ = network::install_domain_filter_fetch(&mgr.client, session_id, true).await; } } } apply_launch_init_scripts(state).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; // Apply stealth anti-detection patches after browser is ready apply_stealth_to_browser(state).await; try_restore_navigation(state).await; Ok(()) } /// Apply AGENT_BROWSER_ENABLE (built-in init scripts like `react-devtools`) /// and AGENT_BROWSER_INIT_SCRIPTS (user-provided files) to the browser so the /// scripts are registered before any page JS runs on the next navigation. /// Also evaluates each script on the current page (if any) so the effect is /// immediate for already-loaded pages. async fn apply_launch_init_scripts(state: &DaemonState) { let Some(mgr) = state.browser.as_ref() else { return; }; // Built-in features via --enable / AGENT_BROWSER_ENABLE. if let Ok(raw) = env::var("AGENT_BROWSER_ENABLE") { for feature in raw .split([',', '\n']) .map(|s| s.trim()) .filter(|s| !s.is_empty()) { match feature { "react-devtools" | "react" => { let _ = mgr.add_script_to_evaluate(react::INSTALL_HOOK_JS).await; } other => { eprintln!("warning: unknown --enable feature '{}'", other); } } } } // User init scripts via --init-script / AGENT_BROWSER_INIT_SCRIPTS. if let Ok(raw) = env::var("AGENT_BROWSER_INIT_SCRIPTS") { for path in raw .split([',', '\n']) .map(|s| s.trim()) .filter(|s| !s.is_empty()) { match fs::read_to_string(path) { Ok(source) => { let _ = mgr.add_script_to_evaluate(&source).await; } Err(e) => { eprintln!("warning: failed to read --init-script '{}': {}", path, e); } } } } } /// Inject stealth scripts into the active browser session. /// Platform-specific hint for relaunching Chrome with CDP enabled. fn chrome_relaunch_hint() -> &'static str { if cfg!(target_os = "macos") { " 1. Quit Chrome completely\n\ 2. Run: open -a \"Google Chrome\" --args --remote-debugging-port=9222\n\ 3. Then retry your agent-browser command" } else if cfg!(target_os = "windows") { " 1. Close Chrome completely\n\ 2. Run: start chrome --remote-debugging-port=9222\n\ 3. Then retry your agent-browser command" } else { " 1. Close Chrome completely\n\ 2. Run: google-chrome --remote-debugging-port=9222\n\ 3. Then retry your agent-browser command" } } /// Called after every successful launch / CDP connect / auto-connect. /// Uses `CdpAttach` mode for external connections (minimal patches) and /// `FullLaunch` mode for newly launched Chrome (all patches). /// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables). fn stealth_enabled() -> bool { !env::var("AGENT_BROWSER_STEALTH") .map(|v| v == "0") .unwrap_or(false) } /// Apply stealth patches to ONE CDP session of the given browser. /// /// Stealth scripts are registered per-session via /// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new /// tabs or cross-origin iframe sessions created after the initial page. We must /// re-apply to every session the user can touch, otherwise automation markers /// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces. async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) { if !stealth_enabled() { return; } // Determine mode: an external attach uses minimal patches (the user's real // Chrome already has a genuine fingerprint — heavy patches create detectable // "lies" like creepjs hasIframeProxy); a fresh launch uses the full set. let mode = if mgr.is_cdp_connection() { stealth::StealthMode::CdpAttach } else { stealth::StealthMode::FullLaunch }; let locale = env::var("AGENT_BROWSER_LOCALE").ok(); if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await { eprintln!("[stealth] failed to apply patches to session {session_id}: {e}"); } // Also inject into the current page (already loaded before our init script). if let Err(e) = stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()) .await { eprintln!("[stealth] failed to patch current page for session {session_id}: {e}"); } } /// Apply stealth to a specific session of the active browser (no-op if no /// browser or stealth disabled). async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) { if let Some(ref mgr) = state.browser { apply_stealth_via_mgr(mgr, session_id).await; } } /// Apply stealth to the active page session (initial connect/launch). async fn apply_stealth_to_browser(state: &DaemonState) { let session_id = match state .browser .as_ref() .and_then(|m| m.active_session_id().ok()) { Some(sid) => sid.to_string(), None => return, }; apply_stealth_to_session(state, &session_id).await; } /// If the previous daemon left a `.restore-url` sidecar (because it was killed /// by a version-mismatch restart), navigate the freshly-connected browser to /// that URL so `agent-browser get url` after `npm i -g` upgrade still reports /// the page the user was on. Read-and-delete: the file is removed regardless /// of whether navigation succeeds, so a stale sidecar can't haunt later /// auto-launches. async fn try_restore_navigation(state: &mut DaemonState) { let path = get_restore_url_path(&state.session_id); let url = match fs::read_to_string(&path) { Ok(s) => s.trim().to_string(), Err(_) => return, }; let _ = fs::remove_file(&path); if url.is_empty() { return; } let Some(mgr) = state.browser.as_mut() else { return; }; state.ref_map.clear(); state.iframe_sessions.clear(); state.active_frame_id = None; match mgr.navigate(&url, super::browser::WaitUntil::Load).await { Ok(_) => { eprintln!( "{} Restored previous URL: {}", crate::color::warning_indicator(), url ); } Err(e) => { eprintln!( "{} Could not restore previous URL ({}): {}", crate::color::warning_indicator(), url, e ); } } } fn launch_options_from_env() -> LaunchOptions { let headed = env::var("AGENT_BROWSER_HEADED") .map(|v| v == "1" || v == "true") .unwrap_or(false); let extensions: Option> = env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| { v.split([',', '\n']) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }); LaunchOptions { headless: !headed, executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), proxy: env::var("AGENT_BROWSER_PROXY").ok(), proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(), proxy_username: env::var("AGENT_BROWSER_PROXY_USERNAME").ok(), proxy_password: env::var("AGENT_BROWSER_PROXY_PASSWORD").ok(), profile: env::var("AGENT_BROWSER_PROFILE").ok(), allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS") .map(|v| v == "1" || v == "true") .unwrap_or(false), args: env::var("AGENT_BROWSER_ARGS") .map(|v| { v.split([',', '\n']) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }) .unwrap_or_default(), extensions, storage_state: env::var("AGENT_BROWSER_STATE").ok(), user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(), ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS") .map(|v| v == "1" || v == "true") .unwrap_or(false), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(), download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(), hide_scrollbars: hide_scrollbars_from_env(), viewport_size: None, use_real_keychain: false, } } fn hide_scrollbars_from_env() -> bool { env::var("AGENT_BROWSER_HIDE_SCROLLBARS") .map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "")) .unwrap_or(true) } fn hide_scrollbars_from_launch_cmd(cmd: &Value) -> bool { cmd.get("hideScrollbars") .and_then(|v| v.as_bool()) .unwrap_or_else(hide_scrollbars_from_env) } async fn try_auto_restore_state(state: &mut DaemonState) { let session_name = match state.session_name.as_deref() { Some(n) if !n.is_empty() => n.to_string(), _ => return, }; if let Some(path) = state::find_auto_state_file(&session_name) { if let Some(ref mgr) = state.browser { if let Ok(session_id) = mgr.active_session_id() { let _ = state::load_state(&mgr.client, session_id, &path).await; } } } } /// Load storage state if a path is configured. /// /// Explicit launch should surface this error. Best-effort callers can ignore /// the returned `Result` and keep their previous behavior. async fn load_storage_state(state: &DaemonState, path: &Option) -> Result<(), String> { if let Some(ref path) = path { if let Some(ref mgr) = state.browser { if let Ok(session_id) = mgr.active_session_id() { state::load_state(&mgr.client, session_id, path).await?; } } } Ok(()) } async fn rollback_failed_launch(state: &mut DaemonState) -> Result<(), String> { let close_error = if let Some(mut mgr) = state.browser.take() { mgr.close().await.err() } else { None }; state.launch_hash = None; state.screencasting = false; state.reset_input_state(); state.ref_map.clear(); state.update_stream_client().await; if let Some(err) = close_error { return Err(err); } Ok(()) } async fn load_storage_state_or_rollback( state: &mut DaemonState, path: &Option, ) -> Result<(), String> { if let Err(err) = load_storage_state(state, path).await { if let Err(close_err) = rollback_failed_launch(state).await { return Err(format!( "{} (also failed to roll back browser after launch: {})", err, close_err )); } return Err(err); } Ok(()) } /// Load storage state from AGENT_BROWSER_STATE if set. async fn try_load_storage_state(state: &DaemonState, path: &Option) { let _ = load_storage_state(state, path).await; } // --------------------------------------------------------------------------- // Phase 1 handlers // --------------------------------------------------------------------------- async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result { let headless = cmd .get("headless") .and_then(|v| v.as_bool()) .unwrap_or(true); let cdp_url = cmd.get("cdpUrl").and_then(|v| v.as_str()); let cdp_port = cmd.get("cdpPort").and_then(|v| v.as_u64()); let auto_connect = cmd .get("autoConnect") .and_then(|v| v.as_bool()) .unwrap_or(false); let extensions: Option> = cmd.get("extensions").and_then(|v| v.as_array()).map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }); let storage_state = cmd.get("storageState").and_then(|v| v.as_str()); let storage_state_owned = storage_state.map(|s| s.to_string()); let launch_options = LaunchOptions { headless, executable_path: cmd .get("executablePath") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()), proxy: cmd.get("proxy").and_then(|v| { v.as_str().map(|s| s.to_string()).or_else(|| { v.get("server") .and_then(|s| s.as_str()) .map(|s| s.to_string()) }) }), proxy_bypass: cmd .get("proxy") .and_then(|v| v.get("bypass")) .and_then(|v| v.as_str()) .map(String::from), proxy_username: cmd .get("proxy") .and_then(|v| v.get("username")) .and_then(|v| v.as_str()) .map(String::from) .or_else(|| env::var("AGENT_BROWSER_PROXY_USERNAME").ok()), proxy_password: cmd .get("proxy") .and_then(|v| v.get("password")) .and_then(|v| v.as_str()) .map(String::from) .or_else(|| env::var("AGENT_BROWSER_PROXY_PASSWORD").ok()), profile: cmd .get("profile") .and_then(|v| v.as_str()) .map(|s| s.to_string()), allow_file_access: cmd .get("allowFileAccess") .and_then(|v| v.as_bool()) .unwrap_or(false), args: cmd .get("args") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(), extensions, storage_state: storage_state.map(String::from), user_agent: cmd .get("userAgent") .and_then(|v| v.as_str()) .map(String::from), ignore_https_errors: cmd .get("ignoreHTTPSErrors") .and_then(|v| v.as_bool()) .unwrap_or(false), color_scheme: cmd .get("colorScheme") .and_then(|v| v.as_str()) .map(String::from), download_path: cmd .get("downloadPath") .and_then(|v| v.as_str()) .map(String::from), hide_scrollbars: hide_scrollbars_from_launch_cmd(cmd), viewport_size: None, use_real_keychain: false, }; let new_hash = launch_hash(&launch_options); // Hash comparison and fast process-exit check are evaluated before the // async is_connection_alive to skip the expensive CDP liveness probe // when a relaunch is already certain. let needs_relaunch = if let Some(ref mut mgr) = state.browser { let is_external = cdp_url.is_some() || cdp_port.is_some() || auto_connect; let was_external = mgr.is_cdp_connection(); let hash_changed = !is_external && state.launch_hash != Some(new_hash); let storage_state_requires_clean_launch = storage_state_owned.is_some() && !is_external; is_external != was_external || hash_changed || storage_state_requires_clean_launch || mgr.has_process_exited() || !mgr.is_connection_alive().await } else { true }; if needs_relaunch { if let Some(ref mut b) = state.browser { b.close().await?; state.browser = None; state.launch_hash = None; state.screencasting = false; state.reset_input_state(); state.update_stream_client().await; } } else { load_storage_state(state, &storage_state_owned).await?; return Ok(json!({ "launched": true, "reused": true })); } state.ref_map.clear(); let has_cdp = cdp_url.is_some() || cdp_port.is_some(); super::browser::validate_launch_options( launch_options.extensions.as_deref(), has_cdp, launch_options.profile.as_deref(), storage_state, launch_options.allow_file_access, launch_options.executable_path.as_deref(), )?; if let Some(url) = cdp_url { state.reset_input_state(); state.browser = Some(BrowserManager::connect_cdp(url).await?); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; load_storage_state_or_rollback(state, &storage_state_owned).await?; apply_launch_init_scripts(state).await; return Ok(json!({ "launched": true })); } if let Some(port) = cdp_port { state.reset_input_state(); state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; load_storage_state_or_rollback(state, &storage_state_owned).await?; apply_launch_init_scripts(state).await; return Ok(json!({ "launched": true })); } if auto_connect { match connect_auto_with_fresh_tab().await { Ok(mgr) => { state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; load_storage_state_or_rollback(state, &storage_state_owned).await?; apply_launch_init_scripts(state).await; apply_stealth_to_browser(state).await; return Ok(json!({ "launched": true })); } Err(_e) => { return Err(format!( "Could not connect to your Chrome browser.\n\n\ If Chrome showed an \"Allow remote debugging?\" dialog, click \ Allow and re-run — that consent is what lets agent-browser attach.\n\n\ Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\ {}\n\n\ Or launch a separate browser that KEEPS your login state:\n \ agent-browser --launch --profile auto open \n\ (plain `--launch` alone uses a temporary EMPTY profile — no cookies, \ no logged-in sessions.)\n\n\ Note: remote debugging is a startup flag, not a Chrome setting — \ chrome://inspect/#remote-debugging only enables target discovery and \ does NOT expose the CDP HTTP API on /json/version. \ A full restart with --remote-debugging-port= is required.", chrome_relaunch_hint(), )); } } } if let Some(provider) = cmd.get("provider").and_then(|v| v.as_str()) { match provider.to_lowercase().as_str() { "ios" => { return launch_ios(cmd, state).await; } "safari" => { return launch_safari(cmd, state).await; } _ => { let conn = providers::connect_provider(provider).await?; let ws_headers = if provider.eq_ignore_ascii_case("agentcore") { providers::take_agentcore_ws_headers() } else { None }; let connect_result = if conn.direct_page { BrowserManager::connect_cdp_direct(&conn.ws_url).await } else if ws_headers.is_some() { BrowserManager::connect_cdp_with_headers(&conn.ws_url, ws_headers).await } else { BrowserManager::connect_cdp(&conn.ws_url).await }; match connect_result { Ok(mgr) => { state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; write_provider_file(&state.session_id, provider); load_storage_state_or_rollback(state, &storage_state_owned).await?; apply_launch_init_scripts(state).await; if let Some(info) = providers::get_agentcore_info() { return Ok(json!({ "launched": true, "provider": provider, "agentCoreSessionId": info.session_id, "agentCoreLiveViewUrl": info.live_view_url })); } return Ok(json!({ "launched": true, "provider": provider })); } Err(e) => { if let Some(ref ps) = conn.session { providers::close_provider_session(ps).await; } return Err(e); } } } } } let engine = cmd .get("engine") .and_then(|v| v.as_str()) .map(String::from) .or_else(|| env::var("AGENT_BROWSER_ENGINE").ok()); // Store proxy credentials for Fetch.authRequired handling let has_proxy_auth = launch_options.proxy_username.is_some(); if has_proxy_auth { let mut creds = state.proxy_credentials.write().await; *creds = Some(( launch_options.proxy_username.clone().unwrap_or_default(), launch_options.proxy_password.clone().unwrap_or_default(), )); } if let Some(ref domains) = cmd .get("allowedDomains") .and_then(|v| v.as_str()) .map(String::from) { let mut df = state.domain_filter.write().await; *df = Some(DomainFilter::new(domains)); } state.engine = engine.as_deref().unwrap_or("chrome").to_string(); write_engine_file(&state.session_id, &state.engine); write_extensions_file(&state.session_id); state.reset_input_state(); state.browser = Some(BrowserManager::launch(launch_options, engine.as_deref()).await?); state.launch_hash = Some(new_hash); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; // Enable Fetch interception (domain filtering and/or proxy auth). // Only call Fetch.enable once to avoid overwriting handleAuthRequests. { let df = state.domain_filter.read().await; let has_domain_filter = df.is_some(); if has_domain_filter || has_proxy_auth { if let Some(ref mgr) = state.browser { if let Ok(session_id) = mgr.active_session_id() { if let Some(ref filter) = *df { let _ = network::install_domain_filter( &mgr.client, session_id, &filter.allowed_domains, has_proxy_auth, ) .await; network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter) .await; } else { // No domain filter, but proxy auth needs Fetch.enable let _ = network::install_domain_filter_fetch( &mgr.client, session_id, has_proxy_auth, ) .await; } } } } } // Load storage state only after Fetch interception is active so replayed // origin navigations go through the same domain and proxy handling as // normal browser traffic. load_storage_state_or_rollback(state, &storage_state_owned).await?; apply_launch_init_scripts(state).await; // Apply stealth patches (the 32 JS patches + HeadlessChrome UA strip in // FullLaunch mode). The fresh-launch path was missing this — only the launch // FLAGS (e.g. --disable-blink-features) were applied, so the JS patches never // ran and navigator.userAgent kept the HeadlessChrome marker. apply_stealth_to_browser(state).await; Ok(json!({ "launched": true })) } async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result { let device_name = cmd.get("deviceName").and_then(|v| v.as_str()); let device_udid = cmd.get("udid").and_then(|v| v.as_str()); let platform_version = cmd.get("platformVersion").and_then(|v| v.as_str()); // Select device (or use default) let device = ios::select_device(device_name, device_udid)?; // Boot simulator if it's not real and not already booted if !device.is_real && device.state != "Booted" { ios::boot_simulator(&device.udid)?; } // Start Appium let mut appium = AppiumManager::connect_or_launch(Some(&device.udid)).await?; // Create iOS Safari session appium .create_ios_session(Some(&device.name), platform_version) .await?; // Create a WebDriverBackend from the Appium session for common commands if let Some(sid) = appium.client.session_id_pub().map(String::from) { let wd_client = super::webdriver::client::WebDriverClient::new_with_session(4723, sid); state.webdriver_backend = Some(WebDriverBackend::new(wd_client)); } state.appium = Some(appium); state.backend_type = BackendType::WebDriver; state.engine = "safari".to_string(); write_engine_file(&state.session_id, &state.engine); write_provider_file(&state.session_id, "ios"); write_extensions_file(&state.session_id); state.reset_input_state(); Ok(json!({ "launched": true, "provider": "ios", "device": device.name, "udid": device.udid, "backend": "webdriver", })) } async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result { let port: u16 = cmd .get("port") .and_then(|v| v.as_u64()) .map(|p| p as u16) .unwrap_or(0); let driver_port = if port > 0 { port } else { 0 }; // Find a free port if none specified let actual_port = if driver_port > 0 { driver_port } else { // Use any available high port let listener = std::net::TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("Failed to find free port: {}", e))?; listener .local_addr() .map_err(|e| format!("Failed to get local address: {}", e))? .port() }; let driver = safari::launch_safaridriver(actual_port)?; let mut client = super::webdriver::client::WebDriverClient::new(actual_port); client .create_session(serde_json::json!({ "browserName": "safari", })) .await?; state.safari_driver = Some(driver); state.webdriver_backend = Some(WebDriverBackend::new(client)); state.backend_type = BackendType::WebDriver; state.engine = "safari".to_string(); write_engine_file(&state.session_id, &state.engine); write_provider_file(&state.session_id, "safari"); write_extensions_file(&state.session_id); state.reset_input_state(); Ok(json!({ "launched": true, "provider": "safari", "port": actual_port, "backend": "webdriver", })) } async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result { let url = cmd .get("url") .and_then(|v| v.as_str()) .ok_or("Missing 'url' parameter")?; { let df = state.domain_filter.read().await; if let Some(ref filter) = *df { filter.check_url(url)?; } } // WebDriver backend path if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { state.ref_map.clear(); wb.navigate(url).await?; let new_url = wb.get_url().await.unwrap_or_else(|_| url.to_string()); let title = wb.get_title().await.unwrap_or_default(); return Ok(json!({ "url": new_url, "title": title })); } } let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let wait_until = cmd .get("waitUntil") .and_then(|v| v.as_str()) .map(WaitUntil::from_str) .unwrap_or(WaitUntil::Load); // If --headers was passed, store them keyed by origin and enable Fetch // interception. The background fetch_handler_task (started on launch) // injects them into matching requests in real-time. let scoped_headers = cmd .get("headers") .and_then(|v| v.as_object()) .filter(|m| !m.is_empty()); if let Some(headers_map) = scoped_headers { if let Some(origin) = url::Url::parse(url) .ok() .map(|u| u.origin().ascii_serialization()) { let headers: HashMap = headers_map .iter() .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) .collect(); let first_origin_header = { let mut map = state.origin_headers.write().await; let first = map.is_empty(); map.insert(origin, headers); first }; // Enable Fetch interception the first time --headers is used. // Fetch.enable is idempotent — safe even if domain filter or // routes already enabled it. Wildcard ensures we see all requests. if first_origin_header { let session_id = mgr.active_session_id()?.to_string(); let has_proxy_creds = state.proxy_credentials.read().await.is_some(); let mut params = json!({ "patterns": [{ "urlPattern": "*" }] }); if has_proxy_creds { params["handleAuthRequests"] = json!(true); } mgr.client .send_command("Fetch.enable", Some(params), Some(&session_id)) .await?; } } } state.ref_map.clear(); state.iframe_sessions.clear(); state.active_frame_id = None; let result = mgr.navigate(url, wait_until).await?; // Adaptive humanize: sample the freshly loaded page for known behavioural // anti-bot vendors and escalate this session to Human if any are present. detect_and_set_humanize(mgr).await; Ok(result) } /// After navigation, probe the page for known anti-bot vendor fingerprints /// (cookies / script URLs / `window` globals) and set this session's humanize /// level accordingly — `Human` when a vendor is detected, else the `Off` /// baseline. Best-effort: any failure leaves the level unchanged. Skipped when /// `AGENT_BROWSER_HUMANIZE` is set, since the override always wins and the probe /// would be wasted work. async fn detect_and_set_humanize(mgr: &BrowserManager) { if std::env::var("AGENT_BROWSER_HUMANIZE").is_ok() { return; } let js = r#"(() => { try { const cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean); const scripts = Array.from(document.scripts, s => s.src || '').filter(Boolean); const re = /_px|bmak|_abck|datadome|reese84|kpsdk|incap_ses|visid_incap|akam/i; const globals = Object.getOwnPropertyNames(window).filter(k => re.test(k)); return { cookies, scripts, globals }; } catch (e) { return {}; } })()"#; let Ok(val) = mgr.evaluate(js, None).await else { return; }; let to_strings = |v: Option<&Value>| -> Vec { v.and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|x| x.as_str().map(String::from)) .collect() }) .unwrap_or_default() }; let signals = humanize::DetectSignals { cookie_names: to_strings(val.get("cookies")), script_urls: to_strings(val.get("scripts")), window_globals: to_strings(val.get("globals")), }; let level = humanize::detect_level(&signals, humanize::HumanizeLevel::Off); humanize::set_detected_level(level); } async fn handle_url(state: &DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let url = wb.get_url().await?; return Ok(json!({ "url": url })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let url = mgr.get_url().await?; Ok(json!({ "url": url })) } fn handle_cdp_url(state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; Ok(json!({ "cdpUrl": mgr.get_cdp_url() })) } async fn handle_inspect(state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; // Shut down any existing inspect server so we always target the current page if let Some(server) = state.inspect_server.take() { server.shutdown(); } let target_id = mgr.active_target_id()?.to_string(); let chrome_hp = mgr.chrome_host_port().to_string(); let proxy_handle = mgr.client.inspect_handle(); let server = InspectServer::start(proxy_handle, target_id, chrome_hp).await?; let url = format!("http://127.0.0.1:{}", server.port()); open_url_in_browser(&url); state.inspect_server = Some(server); Ok(json!({ "opened": true, "url": url })) } fn open_url_in_browser(url: &str) { #[cfg(target_os = "macos")] let result = std::process::Command::new("open").arg(url).spawn(); #[cfg(target_os = "linux")] let result = std::process::Command::new("xdg-open").arg(url).spawn(); #[cfg(target_os = "windows")] let result = std::process::Command::new("cmd") .args(["/c", "start", "", url]) .spawn(); #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] let result: Result = Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "unsupported platform", )); if let Err(e) = result { let _ = writeln!(std::io::stderr(), "[inspect] Failed to open browser: {}", e); } } async fn handle_title(state: &DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let title = wb.get_title().await?; return Ok(json!({ "title": title })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let title = mgr.get_title().await?; Ok(json!({ "title": title })) } async fn handle_content(state: &DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let html = wb.get_content().await?; let url = wb.get_url().await.unwrap_or_default(); return Ok(json!({ "html": html, "origin": url })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let html = mgr.get_content().await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "html": html, "origin": url })) } async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let script = cmd .get("script") .and_then(|v| v.as_str()) .ok_or("Missing 'script' parameter")?; let result = wb.evaluate(script).await?; let url = wb.get_url().await.unwrap_or_default(); return Ok(json!({ "result": result, "origin": url })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let script = cmd .get("script") .and_then(|v| v.as_str()) .ok_or("Missing 'script' parameter")?; let result = mgr.evaluate(script, None).await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "result": result, "origin": url })) } /// Local stealth self-check: reports the active mode, live fingerprint probes, /// and the list of applied overrides — so an agent (or human) can confirm /// stealth is working without driving an external detector, and audit exactly /// what's patched on this path (issue #5). async fn handle_stealth_status(state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let connect = mgr.is_cdp_connection(); let probe_js = r#"(() => { const ua = navigator.userAgent || ''; return { webdriver: navigator.webdriver === true, hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null, plugins: navigator.plugins ? navigator.plugins.length : 0, languages: navigator.languages || [], platform: navigator.platform || '', headlessUA: /Headless/i.test(ua), }; })()"#; let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null); let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true); let has_chrome = p .get("hasWindowChrome") .and_then(|v| v.as_bool()) .unwrap_or(false); let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0); let headless_ua = p .get("headlessUA") .and_then(|v| v.as_bool()) .unwrap_or(false); let checks = json!([ { "name": "navigator.webdriver is false", "pass": !webdriver }, { "name": "window.chrome present", "pass": has_chrome }, { "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins }, { "name": "userAgent has no 'Headless'", "pass": !headless_ua }, ]); let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua; let overrides = if connect { json!([ "navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)", "Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)", "zero JS patches injected — the browser's real fingerprint is used as-is", ]) } else { let iframe_proxy = std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1"); json!([ "navigator.webdriver removed; navigator.languages/locale normalized", "window.chrome / chrome.runtime shimmed; navigator.platform fixed", "WebGL vendor/renderer, plugins, permissions normalized", format!( "srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)", if iframe_proxy { "ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%" } else { "off" } ), format!( "canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)", if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") { "on" } else { "off (opt-in)" } ), "Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL", ]) }; Ok(json!({ "stealthStatus": { "mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" }, "ok": ok, "checks": checks, "overrides": overrides, "probe": p, } })) } async fn handle_close(state: &mut DaemonState) -> Result { if let Some(ref mgr) = state.browser { if let Some(ref session_name) = state.session_name { if let Ok(session_id) = mgr.active_session_id() { let _ = state::save_state( &mgr.client, session_id, None, Some(session_name.as_str()), &state.session_id, mgr.visited_origins(), ) .await; } } } if let Some(ref mut mgr) = state.browser { mgr.close().await?; } state.browser = None; state.launch_hash = None; state.screencasting = false; state.reset_input_state(); state.update_stream_client().await; // Stop background Fetch handler if let Some(task) = state.fetch_handler_task.take() { task.abort(); } { let mut map = state.origin_headers.write().await; map.clear(); } // Close WebDriver sessions if let Some(ref mut wb) = state.webdriver_backend { let _ = wb.close().await; } state.webdriver_backend = None; if let Some(ref mut appium) = state.appium { let _ = appium.close().await; } state.appium = None; if let Some(ref mut driver) = state.safari_driver { driver.kill(); } state.safari_driver = None; state.backend_type = BackendType::Cdp; if let Some(server) = state.inspect_server.take() { server.shutdown(); } state.ref_map.clear(); Ok(json!({ "closed": true })) } // --------------------------------------------------------------------------- // Phase 2 handlers // --------------------------------------------------------------------------- async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let options = SnapshotOptions { selector: cmd .get("selector") .and_then(|v| v.as_str()) .map(String::from), interactive: cmd .get("interactive") .and_then(|v| v.as_bool()) .unwrap_or(false), compact: cmd .get("compact") .and_then(|v| v.as_bool()) .unwrap_or(false), depth: cmd .get("maxDepth") .and_then(|v| v.as_u64()) .map(|d| d as usize), urls: cmd.get("urls").and_then(|v| v.as_bool()).unwrap_or(false), }; state.ref_map.clear(); let tree = snapshot::take_snapshot( &mgr.client, &session_id, &options, &mut state.ref_map, state.active_frame_id.as_deref(), &state.iframe_sessions, ) .await?; let url = mgr.get_url().await.unwrap_or_default(); let refs: serde_json::Map = state .ref_map .entries_sorted() .into_iter() .map(|(ref_id, entry)| { let mut obj = serde_json::Map::new(); obj.insert("role".into(), Value::String(entry.role)); obj.insert("name".into(), Value::String(entry.name)); (ref_id, Value::Object(obj)) }) .collect(); Ok(json!({ "snapshot": tree, "origin": url, "refs": refs })) } async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result { let annotate = cmd .get("annotate") .and_then(|v| v.as_bool()) .unwrap_or(false); if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { if annotate { return Err( "Annotated screenshots are not yet implemented on the WebDriver backend" .to_string(), ); } let base64_data = wb.screenshot().await?; let path = cmd.get("path").and_then(|v| v.as_str()); if let Some(p) = path { let bytes = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &base64_data, ) .map_err(|e| format!("Base64 decode error: {}", e))?; std::fs::write(p, bytes) .map_err(|e| format!("Failed to write screenshot: {}", e))?; return Ok(json!({ "path": p })); } let tmp = format!( "/tmp/screenshot-{}.png", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0) ); let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &base64_data) .map_err(|e| format!("Base64 decode error: {}", e))?; std::fs::write(&tmp, bytes) .map_err(|e| format!("Failed to write screenshot: {}", e))?; return Ok(json!({ "path": tmp })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let format = cmd .get("format") .or_else(|| cmd.get("type")) .and_then(|v| v.as_str()) .unwrap_or("png") .to_string(); let options = ScreenshotOptions { selector: cmd .get("selector") .and_then(|v| v.as_str()) .map(String::from), path: cmd.get("path").and_then(|v| v.as_str()).map(String::from), full_page: cmd .get("fullPage") .and_then(|v| v.as_bool()) .unwrap_or(false), format, quality: cmd .get("quality") .and_then(|v| v.as_i64()) .map(|q| q as i32), annotate, output_dir: cmd .get("screenshotDir") .and_then(|v| v.as_str()) .map(String::from), }; if annotate { state.ref_map.clear(); let _ = snapshot::take_snapshot( &mgr.client, &session_id, &SnapshotOptions { interactive: true, ..SnapshotOptions::default() }, &mut state.ref_map, state.active_frame_id.as_deref(), &state.iframe_sessions, ) .await?; } let result = screenshot::take_screenshot( &mgr.client, &session_id, &state.ref_map, &options, &state.iframe_sessions, ) .await?; let mut response = json!({ "path": result.path }); if !result.annotations.is_empty() { response["annotations"] = serde_json::to_value(&result.annotations) .map_err(|e| format!("Failed to serialize annotations: {}", e))?; } Ok(response) } async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result { let selector = cmd .get("selector") .and_then(|v| v.as_str()) .ok_or("Missing 'selector' parameter")?; if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.click(selector).await?; return Ok(json!({ "clicked": selector })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let new_tab = cmd.get("newTab").and_then(|v| v.as_bool()).unwrap_or(false); if new_tab { use super::element::resolve_element_object_id; let (object_id, effective_session_id) = resolve_element_object_id( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; let call_params = json!({ "objectId": object_id, "functionDeclaration": "function() { var h = this.getAttribute('href'); if (!h) return null; try { return new URL(h, document.baseURI).toString(); } catch(e) { return null; } }", "returnByValue": true }); let call_result = mgr .client .send_command( "Runtime.callFunctionOn", Some(call_params), Some(&effective_session_id), ) .await?; let href = call_result .get("result") .and_then(|r| r.get("value")) .and_then(|v| v.as_str()) .ok_or_else(|| { format!( "Element '{}' does not have an href attribute. --new-tab only works on links.", selector ) })? .to_string(); let mgr = state.browser.as_mut().ok_or("Browser not launched")?; state.ref_map.clear(); mgr.tab_new(Some(&href), None).await?; return Ok(json!({ "clicked": selector, "newTab": true, "url": href })); } let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left"); let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32; interaction::click( &mgr.client, &session_id, &state.ref_map, selector, button, click_count, &state.iframe_sessions, ) .await?; Ok(json!({ "clicked": selector })) } async fn handle_dblclick(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let selector = cmd .get("selector") .and_then(|v| v.as_str()) .ok_or("Missing 'selector' parameter")?; interaction::dblclick( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "clicked": selector })) } async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result { let selector = cmd .get("selector") .and_then(|v| v.as_str()) .ok_or("Missing 'selector' parameter")?; let value = cmd .get("value") .and_then(|v| v.as_str()) .ok_or("Missing 'value' parameter")?; if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.fill(selector, value).await?; return Ok(json!({ "filled": selector })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); interaction::fill( &mgr.client, &session_id, &state.ref_map, selector, value, &state.iframe_sessions, ) .await?; Ok(json!({ "filled": selector })) } async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); // `type --focused `: type into the currently-focused element without a // selector (custom widgets that move focus to a hidden input on open). if cmd .get("focused") .and_then(|v| v.as_bool()) .unwrap_or(false) { let text = cmd .get("text") .and_then(|v| v.as_str()) .ok_or("Missing 'text' parameter")?; interaction::type_text_into_active_context(&mgr.client, &session_id, text, None).await?; return Ok(json!({ "typed": text, "focused": true })); } let selector = cmd .get("selector") .and_then(|v| v.as_str()) .ok_or("Missing 'selector' parameter")?; let text = cmd .get("text") .and_then(|v| v.as_str()) .ok_or("Missing 'text' parameter")?; let clear = cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false); let delay = cmd.get("delay").and_then(|v| v.as_u64()); interaction::type_text( &mgr.client, &session_id, &state.ref_map, selector, text, clear, delay, &state.iframe_sessions, ) .await?; Ok(json!({ "typed": text })) } /// Atomic combobox select: `pick --option ""`. Opens the control /// (so a portal-rendered menu mounts), polls for the option by visible text, then /// fires the full pointer/mouse event sequence on it — covering native `: set the matching option and dispatch input/change. if (el.tagName === 'SELECT') {{ const opt = [...el.options].find(matches); if (!opt) return {{ ok: false, error: 'no