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_socket_dir, INTERNAL_DAEMON_SHUTDOWN_ACTION}; use crate::validation::{is_valid_session_name, session_name_error}; 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::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::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, approved_actions: Vec, } #[derive(Debug, Clone)] struct ActiveProviderSession { session: providers::ProviderSession, plugins: Vec, } /// 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, /// Flat CDP session the dialog opened on. A dialog on a background tab /// must not block commands targeting the active tab. pub session_id: 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, plugin_init_scripts: &[String], enable_features: &[String], init_script_paths: &[String], engine: Option<&str>, connection_kind: &str, connection_target: Option<&str>, ) -> u64 { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut h = DefaultHasher::new(); engine.hash(&mut h); connection_kind.hash(&mut h); connection_target.hash(&mut h); 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); enable_features.hash(&mut h); init_script_paths.hash(&mut h); plugin_init_scripts.hash(&mut h); h.finish() } fn launch_connection_identity( cdp_url: Option<&str>, cdp_port: Option, auto_connect: bool, provider_name: Option<&str>, ) -> (&'static str, Option) { if let Some(url) = cdp_url { return ("cdp-url", Some(url.to_string())); } if let Some(port) = cdp_port { return ("cdp-port", Some(port.to_string())); } if auto_connect { return ("auto-connect", None); } if let Some(provider) = provider_name { return ("provider", Some(provider.to_ascii_lowercase())); } ("local", None) } fn launch_connection_is_external( cdp_url: Option<&str>, cdp_port: Option, auto_connect: bool, provider_name: Option<&str>, ) -> bool { launch_connection_identity(cdp_url, cdp_port, auto_connect, provider_name).0 != "local" } 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 restore_save: String, pub restore_check_url: Option, pub restore_check_text: Option, pub restore_check_fn: Option, pub restore_status: String, pub restore_status_detail: Option, pub restore_loaded_path: Option, pub restore_load_failed: bool, pub restore_validation_pending: bool, pub restore_save_status: String, pub restore_saved_path: Option, /// When the most recent browser-touching command finished. Periodic /// autosaves wait for a quiet period after this so a multi-second save /// never lands in the middle of an active command burst. pub last_command_finished: Option, /// When session state was last saved or a periodic autosave last failed, /// used to enforce the minimum interval between periodic saves. pub last_autosave_attempt: 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, /// A mouse button left logically down because a dialog opened between /// mousePressed and mouseReleased; released when the dialog is resolved. pub pending_pointer_release: 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)>, /// Init script sources returned by launch mutator plugins for this launch. pub plugin_init_scripts: Vec, /// Provider cleanup metadata for the active external browser session. active_provider_session: Option, /// Actions already approved while replaying a confirmed command. confirmed_policy_actions: HashSet, } 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(), restore_save: env::var("AGENT_BROWSER_RESTORE_SAVE") .ok() .unwrap_or_else(|| "auto".to_string()), restore_check_url: env::var("AGENT_BROWSER_RESTORE_CHECK_URL").ok(), restore_check_text: env::var("AGENT_BROWSER_RESTORE_CHECK_TEXT").ok(), restore_check_fn: env::var("AGENT_BROWSER_RESTORE_CHECK_FN").ok(), restore_status: "not_configured".to_string(), restore_status_detail: None, restore_loaded_path: None, restore_load_failed: false, restore_validation_pending: false, restore_save_status: "not_attempted".to_string(), restore_saved_path: None, last_command_finished: None, last_autosave_attempt: None, 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, pending_pointer_release: 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()), // README documents 25s, intentionally below the CLI's 30s IPC // read timeout so the daemon reports a proper timeout error // instead of the client dying with EAGAIN and retrying. default_timeout_ms: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(25_000), viewport: None, plugin_init_scripts: Vec::new(), active_provider_session: None, confirmed_policy_actions: HashSet::new(), } } /// 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; } } } 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(); mgr.add_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, session_id: event.session_id.clone(), }); } } } "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(); } } } fn append_launch_mutator_policy_actions_for( actions: &mut Vec, plugins: &[crate::plugins::PluginConfig], ) { for plugin in crate::plugins::resolved_plugins_with_capability( plugins, crate::plugins::CAPABILITY_LAUNCH_MUTATE, ) { actions.push(crate::plugins::plugin_policy_action( &plugin.name, crate::plugins::CAPABILITY_LAUNCH_MUTATE, )); } } fn append_browser_provider_policy_action_for( actions: &mut Vec, provider: &str, plugins: &[crate::plugins::PluginConfig], ) { if crate::plugins::find_plugin(plugins, provider).is_some_and(|plugin| { crate::plugins::plugin_has_capability(plugin, crate::plugins::CAPABILITY_BROWSER_PROVIDER) }) { actions.push(crate::plugins::plugin_policy_action( provider, crate::plugins::CAPABILITY_BROWSER_PROVIDER, )); } } fn append_credential_policy_action_for( actions: &mut Vec, provider: &str, plugins: &[crate::plugins::PluginConfig], ) { if crate::plugins::find_plugin(plugins, provider).is_some_and(|plugin| { crate::plugins::plugin_has_capability(plugin, crate::plugins::CAPABILITY_CREDENTIAL_READ) }) { actions.push(crate::plugins::plugin_policy_action( provider, crate::plugins::CAPABILITY_CREDENTIAL_READ, )); } } fn plugins_from_command_or_env(cmd: &Value) -> Vec { cmd.get("plugins") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) .unwrap_or_else(crate::plugins::plugins_from_env) } fn reset_restore_runtime_state(state: &mut DaemonState) { state.restore_status = "pending".to_string(); state.restore_status_detail = None; state.restore_loaded_path = None; state.restore_load_failed = false; state.restore_validation_pending = false; state.restore_save_status = "not_attempted".to_string(); state.restore_saved_path = None; } fn command_restore_check_fields( cmd: &Value, ) -> Option<(Option, Option, Option)> { let has_check_field = cmd.get("restoreCheckUrl").is_some() || cmd.get("restoreCheckText").is_some() || cmd.get("restoreCheckFn").is_some(); if !has_check_field { return None; } Some(( cmd.get("restoreCheckUrl") .and_then(|v| v.as_str()) .map(ToString::to_string), cmd.get("restoreCheckText") .and_then(|v| v.as_str()) .map(ToString::to_string), cmd.get("restoreCheckFn") .and_then(|v| v.as_str()) .map(ToString::to_string), )) } fn restore_checks_are_configured( checks: &(Option, Option, Option), ) -> bool { checks.0.is_some() || checks.1.is_some() || checks.2.is_some() } fn reconcile_restore_check_change( state: &mut DaemonState, checks: &(Option, Option, Option), ) { if restore_checks_are_configured(checks) { if matches!( state.restore_status.as_str(), "loaded" | "loaded_but_invalid" ) { state.restore_status = "loaded".to_string(); state.restore_status_detail = None; state.restore_load_failed = false; state.restore_validation_pending = true; } } else { state.restore_validation_pending = false; if state.restore_status == "loaded_but_invalid" { state.restore_status = "loaded".to_string(); state.restore_status_detail = None; state.restore_load_failed = false; } } } fn apply_restore_config_from_command(cmd: &Value, state: &mut DaemonState) -> Result<(), String> { validate_restore_config_from_command(cmd)?; let restore_key = cmd.get("restoreKey").and_then(|v| v.as_str()); let old_checks = ( state.restore_check_url.clone(), state.restore_check_text.clone(), state.restore_check_fn.clone(), ); if let Some(restore_key) = restore_key { if !restore_key.is_empty() { if state.session_name.as_deref() != Some(restore_key) { reset_restore_runtime_state(state); } state.session_name = Some(restore_key.to_string()); if state.restore_status == "not_configured" { state.restore_status = "pending".to_string(); } } } if let Some(policy) = cmd.get("restoreSave").map(|v| v.as_str().unwrap_or("auto")) { state.restore_save = policy.to_string(); } if let Some(new_checks) = command_restore_check_fields(cmd) { state.restore_check_url = new_checks.0.clone(); state.restore_check_text = new_checks.1.clone(); state.restore_check_fn = new_checks.2.clone(); if old_checks != new_checks { reconcile_restore_check_change(state, &new_checks); } } Ok(()) } fn validate_restore_config_from_command(cmd: &Value) -> Result<(), String> { let restore_key = cmd.get("restoreKey").and_then(|v| v.as_str()); if let Some(restore_key) = restore_key { if !restore_key.is_empty() && !is_valid_session_name(restore_key) { return Err(session_name_error(restore_key)); } } let restore_save = cmd.get("restoreSave").map(|v| v.as_str().unwrap_or("auto")); if let Some(policy) = restore_save { if !matches!(policy, "auto" | "always" | "never") { return Err(format!( "Invalid restore save policy '{}'. Use auto, always, or never.", policy )); } } Ok(()) } fn command_changes_restore_key(cmd: &Value, state: &DaemonState) -> bool { cmd.get("restoreKey") .and_then(|v| v.as_str()) .filter(|key| !key.is_empty()) .is_some_and(|key| state.session_name.as_deref() != Some(key)) } fn has_active_browser_session(state: &DaemonState) -> bool { state.browser.is_some() || state.active_provider_session.is_some() } async fn apply_restore_config_after_confirmation( cmd: &Value, state: &mut DaemonState, ) -> Result { let restore_key_changed = command_changes_restore_key(cmd, state); let had_browser = has_active_browser_session(state); if restore_key_changed && had_browser { let _ = auto_save_restore_state(state).await; let _ = close_current_browser(state).await; } apply_restore_config_from_command(cmd, state)?; Ok(restore_key_changed && had_browser) } fn remember_active_provider_session( state: &mut DaemonState, session: Option, plugins: &[crate::plugins::PluginConfig], ) { state.active_provider_session = session.map(|session| ActiveProviderSession { session, plugins: plugins.to_vec(), }); } async fn close_active_provider_session(state: &mut DaemonState) { if let Some(active) = state.active_provider_session.take() { providers::close_provider_session_with_plugins(&active.session, &active.plugins).await; } } pub(crate) async fn close_current_browser(state: &mut DaemonState) -> Result<(), String> { let close_error = if let Some(mut mgr) = state.browser.take() { mgr.close().await.err() } else { None }; close_active_provider_session(state).await; state.launch_hash = None; state.screencasting = false; state.reset_input_state(); state.update_stream_client().await; if let Some(err) = close_error { return Err(err); } Ok(()) } fn provider_plugin_launch_options_from_command(cmd: &Value) -> Value { let mut options = serde_json::Map::new(); if let Some(headless) = cmd.get("headless").and_then(|v| v.as_bool()) { options.insert("headed".to_string(), json!(!headless)); } for key in ["engine", "userAgent", "colorScheme"] { if let Some(value) = cmd.get(key) { options.insert(key.to_string(), value.clone()); } } Value::Object(options) } fn skip_launch_action(action: &str) -> bool { if action == INTERNAL_DAEMON_SHUTDOWN_ACTION { return true; } matches!( action, "" | "launch" | "close" | "read" | "har_stop" | "credentials_set" | "credentials_get" | "credentials_delete" | "credentials_list" | "auth_save" | "auth_show" | "auth_delete" | "auth_list" | "confirm" | "deny" | "state_list" | "state_show" | "state_clear" | "state_clean" | "state_rename" | "device_list" | "stream_enable" | "stream_disable" | "stream_status" | "session_info" ) } fn should_validate_restore_after_action(action: &str) -> bool { action != "launch" } fn policy_actions_for_command( cmd: &Value, action: &str, needs_implicit_launch: bool, ) -> Vec { let mut actions = vec![action.to_string()]; if action == "auth_login" { if let Some(provider) = cmd.get("credentialProvider").and_then(|v| v.as_str()) { let plugins = plugins_from_command_or_env(cmd); append_credential_policy_action_for(&mut actions, provider, &plugins); } } if action == "launch" { let plugins = plugins_from_command_or_env(cmd); if let Some(provider) = cmd.get("provider").and_then(|v| v.as_str()) { append_browser_provider_policy_action_for(&mut actions, provider, &plugins); } let local_launch = cmd.get("provider").is_none() && cmd.get("cdpUrl").is_none() && cmd.get("cdpPort").is_none() && !cmd .get("autoConnect") .and_then(|v| v.as_bool()) .unwrap_or(false); if local_launch { append_launch_mutator_policy_actions_for(&mut actions, &plugins); } } else if !skip_launch_action(action) && needs_implicit_launch { let plugins = plugins_from_command_or_env(cmd); let provider_launch = env::var("AGENT_BROWSER_PROVIDER") .ok() .map(|provider| provider.to_lowercase()) .filter(|provider| !provider.is_empty() && provider != "ios" && provider != "safari"); if let Some(provider) = provider_launch { append_browser_provider_policy_action_for(&mut actions, &provider, &plugins); } else { append_launch_mutator_policy_actions_for(&mut actions, &plugins); } } actions } pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or(""); let id = cmd .get("id") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let cmd_start = std::time::Instant::now(); if let Err(err) = validate_restore_config_from_command(cmd) { return error_response(&id, &err); } if action == INTERNAL_DAEMON_SHUTDOWN_ACTION { let mut resp = match handle_close(state).await { Ok(data) => success_response(&id, data), Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)), }; inject_lifecycle(&mut resp, state, false, false, false); return resp; } if let Some(ref server) = state.stream_server { let mut broadcast_cmd; let has_internal_fields = cmd.get("plugins").is_some() || cmd.get("restoreKey").is_some() || cmd.get("restoreSave").is_some() || cmd.get("restoreCheckUrl").is_some() || cmd.get("restoreCheckText").is_some() || cmd.get("restoreCheckFn").is_some(); let cmd_for_broadcast = if has_internal_fields { broadcast_cmd = cmd.clone(); if let Some(obj) = broadcast_cmd.as_object_mut() { obj.remove("plugins"); obj.remove("restoreKey"); obj.remove("restoreSave"); obj.remove("restoreCheckUrl"); obj.remove("restoreCheckText"); obj.remove("restoreCheckFn"); } &broadcast_cmd } else { cmd }; server.broadcast_command(action, &id, cmd_for_broadcast); } // Drain and apply pending CDP events (console, errors, screencast frames, target lifecycle) state.drain_cdp_events_background().await; // Keep element resolution in sync with the `frame` selection (see // element::set_active_frame for why this is mirrored). super::element::set_active_frame(state.active_frame_id.as_deref()); let skip_launch = skip_launch_action(action); let restore_key_change_needs_launch = !skip_launch && command_changes_restore_key(cmd, state) && has_active_browser_session(state); let needs_launch = if !skip_launch { // Check if existing connection is stale and needs re-launch. // This must happen before policy evaluation so plugin capability // actions are gated when recovery relaunches would invoke plugins. if restore_key_change_needs_launch { true } else if let Some(ref mut mgr) = state.browser { mgr.has_process_exited() || !mgr.is_connection_alive().await } else { true } } else { false }; let mut lifecycle_reused = false; let mut lifecycle_launched = false; let mut lifecycle_relaunched_browser = false; let policy_actions = policy_actions_for_command(cmd, action, needs_launch); // Hot-reload and check action policy if let Some(ref mut policy) = state.policy { let _ = policy.reload(); let mut confirmation_required: Option = None; for policy_action in &policy_actions { match policy.check(policy_action) { PolicyResult::Allow => {} PolicyResult::Deny(reason) => { return error_response( &id, &format!("Action '{}' denied by policy: {}", policy_action, reason), ); } PolicyResult::RequiresConfirmation => { if !state.confirmed_policy_actions.contains(policy_action) && confirmation_required.is_none() { confirmation_required = Some(policy_action.to_string()); } } } } if let Some(policy_action) = confirmation_required { state.pending_confirmation = Some(PendingConfirmation { action: policy_action.clone(), cmd: cmd.clone(), approved_actions: state.confirmed_policy_actions.iter().cloned().collect(), }); return json!({ "id": id, "success": true, "data": { "confirmation_required": true, "confirmation_id": id, "action": policy_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 { for policy_action in &policy_actions { if state.confirmed_policy_actions.contains(policy_action) { continue; } if ca.requires_confirmation(policy_action) { state.pending_confirmation = Some(PendingConfirmation { action: policy_action.to_string(), cmd: cmd.clone(), approved_actions: state.confirmed_policy_actions.iter().cloned().collect(), }); return json!({ "id": id, "success": true, "data": { "confirmation_required": true, "confirmation_id": id, "action": policy_action, }, }); } } } } let restore_transition_closed_browser = match apply_restore_config_after_confirmation(cmd, state).await { Ok(closed_browser) => closed_browser, Err(err) => return error_response(&id, &err), }; if !skip_launch { if needs_launch { lifecycle_relaunched_browser = restore_transition_closed_browser || state.browser.is_some() || state.active_provider_session.is_some(); if state.browser.is_some() || state.active_provider_session.is_some() { let _ = auto_save_restore_state(state).await; let _ = close_current_browser(state).await; } if let Err(e) = auto_launch(state, plugins_from_command_or_env(cmd)).await { return error_response(&id, &format!("Auto-launch failed: {}", e)); } lifecycle_launched = true; } else { lifecycle_reused = true; } 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 ), ); } // A pending confirm/prompt dialog blocks the renderer's main thread, so // any command that touches the page would hang until the client read // timeout. Fail fast with instructions instead. Actions in skip_launch // never touch the page; dialog/screenshot/url/title are browser-side. // Only a dialog on the ACTIVE tab blocks: one on a background tab leaves // the active tab's renderer responsive. if let Some(ref dialog) = state.pending_dialog { let active_session = state .browser .as_ref() .and_then(|m| m.active_session_id().ok().map(|s| s.to_string())); let on_active_tab = match (&dialog.session_id, &active_session) { (Some(dialog_sid), Some(active_sid)) => dialog_sid == active_sid, // No session on the event = top-level page dialog; no browser = be safe. _ => true, }; // Tab and session management must stay usable: switching or closing // tabs is exactly how an agent escapes a tab blocked by a dialog. let read_touches_active_tab = action == "read" && cmd.get("url").is_none() && cmd.get("llms").is_none() && !cmd .get("requireMd") .and_then(|v| v.as_bool()) .unwrap_or(false); let safe_during_dialog = (skip_launch && !read_touches_active_tab) || matches!( action, "dialog" | "screenshot" | "url" | "title" | "tab_list" | "tab_new" | "tab_switch" | "tab_close" ); if on_active_tab && !safe_during_dialog { return error_response( &id, &format!( "A JavaScript {} dialog is blocking the page: \"{}\". Resolve it with `dialog accept` or `dialog dismiss`, then retry `{}`.", dialog.dialog_type, dialog.message, action ), ); } } let result = match action { "launch" => handle_launch(cmd, state).await, "navigate" => handle_navigate(cmd, state).await, "read" => handle_read(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, "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, "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, "session_info" => handle_session_info(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)), }; if result.is_ok() && should_validate_restore_after_action(action) { validate_restore_if_pending(state).await; } // Stamp browser-touching commands so periodic autosave waits for an // active command burst to settle before collecting state. Stamped even on // error: a failed click can still have navigated. if !skip_launch { state.last_command_finished = Some(std::time::Instant::now()); } let mut resp = match result { Ok(data) => success_response(&id, data), Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)), }; inject_lifecycle( &mut resp, state, lifecycle_reused, lifecycle_launched, lifecycle_relaunched_browser, ); // Re-drain so a dialog opened by THIS command is reflected in the warning // below; events are otherwise only drained at the start of a command. state.drain_cdp_events_background().await; // 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?; mgr.tab_new(None, None).await?; let session_id = mgr.active_session_id()?.to_string(); let _ = mgr .client .send_command("Page.bringToFront", None, Some(&session_id)) .await; Ok(mgr) } async fn auto_launch( state: &mut DaemonState, plugins: Vec, ) -> Result<(), String> { let mut options = launch_options_from_env(); state.plugin_init_scripts.clear(); // 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(); let enable_features = launch_enable_features_from_env(); let init_script_paths = launch_init_script_paths_from_env(); // 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?; let hash = launch_hash( &options, &state.plugin_init_scripts, &enable_features, &init_script_paths, engine.as_deref(), "cdp-url", Some(cdp.as_str()), ); 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; apply_launch_init_scripts(state, &enable_features, &init_script_paths).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; return Ok(()); } if env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok() { let hash = launch_hash( &options, &state.plugin_init_scripts, &enable_features, &init_script_paths, engine.as_deref(), "auto-connect", None, ); state.reset_input_state(); state.browser = Some(connect_auto_with_fresh_tab().await?); state.launch_hash = Some(hash); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; apply_launch_init_scripts(state, &enable_features, &init_script_paths).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).await; return Ok(()); } // 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_with_plugins(&p, &plugins).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) => { let hash = launch_hash( &options, &state.plugin_init_scripts, &enable_features, &init_script_paths, engine.as_deref(), "provider", Some(p.as_str()), ); state.reset_input_state(); state.browser = Some(mgr); state.launch_hash = Some(hash); remember_active_provider_session(state, conn.session.clone(), &plugins); 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, &enable_features, &init_script_paths).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_with_plugins(ps, &plugins).await; } return Err(format!("Provider '{}' connection failed: {}", p, e)); } } } } apply_launch_mutator_plugins(state, &mut options, plugins).await?; write_extensions_file_from_paths(&state.session_id, options.extensions.as_deref()); let hash = launch_hash( &options, &state.plugin_init_scripts, &enable_features, &init_script_paths, engine.as_deref(), "local", None, ); 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, &enable_features, &init_script_paths).await; try_auto_restore_state(state).await; try_load_storage_state(state, &storage_state_path).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. fn launch_enable_features_from_env() -> Vec { env::var("AGENT_BROWSER_ENABLE") .ok() .map(|raw| { raw.split([',', '\n']) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }) .unwrap_or_default() } fn launch_init_script_paths_from_env() -> Vec { env::var("AGENT_BROWSER_INIT_SCRIPTS") .ok() .map(|raw| { raw.split([',', '\n']) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }) .unwrap_or_default() } fn string_array_from_command(cmd: &Value, key: &str) -> Option> { cmd.get(key).and_then(|v| v.as_array()).map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) } async fn apply_launch_init_scripts( state: &DaemonState, enable_features: &[String], init_script_paths: &[String], ) { let Some(mgr) = state.browser.as_ref() else { return; }; for feature in enable_features { match feature.as_str() { "react-devtools" | "react" => { let _ = mgr.add_script_to_evaluate(react::INSTALL_HOOK_JS).await; } other => { eprintln!("warning: unknown --enable feature '{}'", other); } } } for path in init_script_paths { 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); } } } for source in &state.plugin_init_scripts { let _ = mgr.add_script_to_evaluate(source).await; } } async fn apply_launch_mutator_plugins( state: &mut DaemonState, options: &mut LaunchOptions, plugins: Vec, ) -> Result<(), String> { state.plugin_init_scripts.clear(); if plugins.is_empty() { return Ok(()); } let request = json!({ "session": state.session_id, "launchOptions": { "headless": options.headless, "engine": env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()), "args": options.args.clone(), "extensions": options.extensions.clone(), "userAgent": options.user_agent.clone(), "colorScheme": options.color_scheme.clone(), "downloadPath": options.download_path.clone(), "hideScrollbars": options.hide_scrollbars, "allowFileAccess": options.allow_file_access, } }); for mutation in crate::plugins::launch_mutations_from_plugins(&plugins, request).await? { options.args.extend(mutation.args); if !mutation.extensions.is_empty() { options .extensions .get_or_insert_with(Vec::new) .extend(mutation.extensions); } if let Some(user_agent) = mutation.user_agent { options.user_agent = Some(user_agent); } state.plugin_init_scripts.extend(mutation.init_scripts); } Ok(()) } 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(), _ => { state.restore_status = "not_configured".to_string(); state.restore_status_detail = None; state.restore_loaded_path = None; state.restore_load_failed = false; state.restore_validation_pending = false; 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() { match state::load_state(&mgr.client, session_id, &path).await { Ok(()) => { state.restore_status = "loaded".to_string(); state.restore_status_detail = None; state.restore_loaded_path = Some(path.clone()); state.restore_load_failed = false; state.restore_validation_pending = state.restore_check_url.is_some() || state.restore_check_text.is_some() || state.restore_check_fn.is_some(); } Err(err) => { state.restore_status = "load_failed".to_string(); state.restore_status_detail = Some(err); state.restore_loaded_path = Some(path); state.restore_load_failed = true; state.restore_validation_pending = false; } } } } } else { state.restore_status = "missing".to_string(); state.restore_status_detail = None; state.restore_loaded_path = None; state.restore_load_failed = false; state.restore_validation_pending = false; } } async fn validate_restore_if_pending(state: &mut DaemonState) { if !state.restore_validation_pending { return; } state.restore_validation_pending = false; match validate_restored_state(state).await { Ok(()) => { state.restore_status = "loaded".to_string(); state.restore_status_detail = None; state.restore_load_failed = false; } Err(err) => { state.restore_status = "loaded_but_invalid".to_string(); state.restore_status_detail = Some(err); state.restore_load_failed = true; } } } async fn validate_restored_state(state: &DaemonState) -> Result<(), String> { let Some(ref mgr) = state.browser else { return Ok(()); }; let session_id = mgr.active_session_id()?.to_string(); let timeout_ms = state.default_timeout_ms.min(2_000); if let Some(ref pattern) = state.restore_check_url { let url = mgr.get_url().await.unwrap_or_default(); if !route_url_matches(pattern, &url) { return Err(format!( "restore URL validation failed: '{}' did not match '{}'", url, pattern )); } } if let Some(ref text) = state.restore_check_text { wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?; } if let Some(ref expression) = state.restore_check_fn { wait_for_function(&mgr.client, &session_id, expression, timeout_ms).await?; } Ok(()) } fn mark_explicit_storage_state_loaded(state: &mut DaemonState, path: &str) { if state.session_name.is_none() && state.restore_status == "not_configured" { return; } state.restore_status = "loaded".to_string(); state.restore_status_detail = None; state.restore_loaded_path = Some(path.to_string()); state.restore_load_failed = false; state.restore_validation_pending = false; state.restore_save_status = "not_attempted".to_string(); state.restore_saved_path = None; } /// Quiet time required after the last command before a periodic autosave may /// run, so a multi-second save never stalls an active command burst. const AUTOSAVE_QUIET_PERIOD_MS: u64 = 2_000; /// Whether the daemon's periodic tick should attempt an autosave right now. /// /// Timing and dialog pre-checks only; restore-key, save-policy, and browser /// gating live in `maybe_autosave_restore_state` and `auto_save_restore_state`. fn autosave_due(state: &DaemonState, interval_ms: u64) -> bool { if interval_ms == 0 { return false; } // A JS dialog blocks the renderer's main thread, so the storage-collection // evaluate would hang until its CDP timeout. Wait for the dialog instead. if state.pending_dialog.is_some() { return false; } let now = std::time::Instant::now(); if let Some(t) = state.last_command_finished { if now.duration_since(t) < std::time::Duration::from_millis(AUTOSAVE_QUIET_PERIOD_MS) { return false; } } if let Some(t) = state.last_autosave_attempt { if now.duration_since(t) < std::time::Duration::from_millis(interval_ms) { return false; } } true } /// Periodically persist session state while a browser is open with a restore /// key configured, so a browser the user closes by hand (which kills CDP and /// makes save-on-close impossible) loses at most roughly `interval_ms` of /// state. Saves stay eligible even without new commands: the page itself can /// mutate cookies and storage while idle (token refreshes, background /// requests), so idle sessions are re-saved on every interval too. /// /// Called from the daemon's background tick. Failures are expected while a /// page is mid-navigation; the next interval retries, and /// `auto_save_restore_state` records the status either way. pub(crate) async fn maybe_autosave_restore_state(state: &mut DaemonState, interval_ms: u64) { if !autosave_due(state, interval_ms) { return; } // Sessions without a restore key, or with saving disabled, never autosave. if state.session_name.is_none() || state.restore_save == "never" { return; } // No browser means nothing to collect from. if state.browser.is_none() { return; } state.last_autosave_attempt = Some(std::time::Instant::now()); let _ = auto_save_restore_state(state).await; } pub(crate) async fn auto_save_restore_state( state: &mut DaemonState, ) -> Result, String> { validate_restore_if_pending(state).await; let Some(session_name) = state.session_name.clone() else { state.restore_save_status = "not_configured".to_string(); state.restore_saved_path = None; return Ok(None); }; match state.restore_save.as_str() { "never" => { state.restore_save_status = "disabled".to_string(); state.restore_saved_path = None; return Ok(None); } "auto" if state.restore_load_failed => { state.restore_save_status = "skipped_restore_failed".to_string(); state.restore_saved_path = None; return Ok(None); } "auto" | "always" => {} other => { state.restore_save_status = "invalid_policy".to_string(); state.restore_saved_path = None; return Err(format!( "Invalid restore save policy '{}'. Use auto, always, or never.", other )); } } let Some(ref mgr) = state.browser else { state.restore_save_status = "no_browser".to_string(); state.restore_saved_path = None; return Ok(None); }; let active_session_id = mgr.active_session_id()?.to_string(); match state::save_auto_state_transactional( &mgr.client, &active_session_id, &session_name, &state.session_id, mgr.visited_origins(), ) .await { Ok(path) => { state.restore_save_status = "saved".to_string(); state.restore_saved_path = Some(path.clone()); // Saves from any path (close, relaunch, restore-key change) reset // the periodic interval so the tick doesn't immediately re-save. state.last_autosave_attempt = Some(std::time::Instant::now()); Ok(Some(path)) } Err(err) => { state.restore_save_status = "error".to_string(); state.restore_saved_path = None; Err(err) } } } /// 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: &mut DaemonState, path: &Option) -> Result<(), String> { if let Some(ref path) = path { let mut loaded = false; 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?; loaded = true; } } if loaded { mark_explicit_storage_state_loaded(state, path); } } Ok(()) } async fn rollback_failed_launch(state: &mut DaemonState) -> Result<(), String> { let close_result = close_current_browser(state).await; state.ref_map.clear(); close_result } 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: &mut 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 provider_name = cmd.get("provider").and_then(|v| v.as_str()); let enable_features = string_array_from_command(cmd, "enable").unwrap_or_else(launch_enable_features_from_env); let init_script_paths = string_array_from_command(cmd, "initScripts") .unwrap_or_else(launch_init_script_paths_from_env); 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 engine = cmd .get("engine") .and_then(|v| v.as_str()) .map(String::from) .or_else(|| env::var("AGENT_BROWSER_ENGINE").ok()); let mut 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, }; state.plugin_init_scripts.clear(); let local_launch = cdp_url.is_none() && cdp_port.is_none() && !auto_connect && provider_name.is_none(); if local_launch { apply_launch_mutator_plugins(state, &mut launch_options, plugins_from_command_or_env(cmd)) .await?; } let (connection_kind, connection_target) = launch_connection_identity(cdp_url, cdp_port, auto_connect, provider_name); let new_hash = launch_hash( &launch_options, &state.plugin_init_scripts, &enable_features, &init_script_paths, engine.as_deref(), connection_kind, connection_target.as_deref(), ); // 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 = launch_connection_is_external(cdp_url, cdp_port, auto_connect, provider_name); let was_external = mgr.is_cdp_connection(); let hash_changed = 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 }; let had_browser_before_launch = state.browser.is_some() || state.active_provider_session.is_some(); if needs_relaunch { if had_browser_before_launch { let _ = auto_save_restore_state(state).await; close_current_browser(state).await?; } } else { load_storage_state(state, &storage_state_owned).await?; return Ok(json!({ "launched": true, "reused": true, "relaunchedBrowser": false })); } 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.launch_hash = Some(new_hash); state.subscribe_to_browser_events(); state.start_fetch_handler(); state.start_dialog_handler(); state.update_stream_client().await; apply_launch_init_scripts(state, &enable_features, &init_script_paths).await; try_auto_restore_state(state).await; load_storage_state_or_rollback(state, &storage_state_owned).await?; return Ok(json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch })); } if let Some(port) = cdp_port { state.reset_input_state(); state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).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; apply_launch_init_scripts(state, &enable_features, &init_script_paths).await; try_auto_restore_state(state).await; load_storage_state_or_rollback(state, &storage_state_owned).await?; return Ok(json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch })); } if auto_connect { state.reset_input_state(); state.browser = Some(connect_auto_with_fresh_tab().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; apply_launch_init_scripts(state, &enable_features, &init_script_paths).await; try_auto_restore_state(state).await; load_storage_state_or_rollback(state, &storage_state_owned).await?; return Ok(json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch })); } if let Some(provider) = provider_name { match provider.to_lowercase().as_str() { "ios" => { return launch_ios(cmd, state).await; } "safari" => { return launch_safari(cmd, state).await; } _ => { let command_plugins = plugins_from_command_or_env(cmd); let conn = providers::connect_provider_with_plugins_and_options( provider, &command_plugins, Some(provider_plugin_launch_options_from_command(cmd)), ) .await?; let provider_metadata = conn.metadata.clone(); 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.launch_hash = Some(new_hash); remember_active_provider_session( state, conn.session.clone(), &command_plugins, ); 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); apply_launch_init_scripts(state, &enable_features, &init_script_paths) .await; try_auto_restore_state(state).await; load_storage_state_or_rollback(state, &storage_state_owned).await?; if let Some(info) = providers::get_agentcore_info() { return Ok(json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch, "provider": provider, "agentCoreSessionId": info.session_id, "agentCoreLiveViewUrl": info.live_view_url })); } if let Some(metadata) = provider_metadata { return Ok(json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch, "provider": provider, "providerMetadata": metadata })); } return Ok( json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch, "provider": provider }), ); } Err(e) => { if let Some(ref ps) = conn.session { providers::close_provider_session_with_plugins(ps, &command_plugins) .await; } return Err(e); } } } } } // 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_from_paths(&state.session_id, launch_options.extensions.as_deref()); 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; } } } } } apply_launch_init_scripts(state, &enable_features, &init_script_paths).await; try_auto_restore_state(state).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. Explicit storage state wins over auto-restore. load_storage_state_or_rollback(state, &storage_state_owned).await?; Ok(json!({ "launched": true, "relaunchedBrowser": had_browser_before_launch })) } 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; mgr.navigate(url, wait_until).await } 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 })) } async fn handle_read(cmd: &Value, state: &DaemonState) -> Result { let mut options = crate::read::options_from_command(cmd)?; if let Some(allowed_domains) = { let df = state.domain_filter.read().await; df.as_ref().map(|filter| filter.allowed_domains.clone()) } { if !allowed_domains.is_empty() { options.enforced_allowed_domains.push(allowed_domains); } } if let Some(url) = cmd.get("url").and_then(|v| v.as_str()) { return crate::read::run_read(url, options).await; } let url_data = handle_url(state).await?; let active_url = url_data .get("url") .and_then(|v| v.as_str()) .filter(|url| !url.is_empty()) .ok_or_else(|| "Active tab has no URL".to_string())?; crate::read::check_allowed_active_url_for_options(active_url, &options)?; if options.llms.is_some() || options.require_md { return crate::read::run_read(active_url, options).await; } let content_data = handle_content(state).await?; let html = content_data .get("html") .and_then(|v| v.as_str()) .ok_or_else(|| "Active tab content is unavailable".to_string())?; let origin = content_data .get("origin") .and_then(|v| v.as_str()) .filter(|origin| !origin.is_empty()) .unwrap_or(active_url); Ok(crate::read::read_json_from_active_html( origin, html.to_string(), &options, )) } 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 })) } async fn handle_close(state: &mut DaemonState) -> Result { let save_result = auto_save_restore_state(state).await; close_current_browser(state).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(); match save_result { Ok(Some(path)) => Ok(json!({ "closed": true, "restoreStatus": state.restore_status, "saveStatus": state.restore_save_status, "statePath": path })), Ok(None) => Ok(json!({ "closed": true, "restoreStatus": state.restore_status, "saveStatus": state.restore_save_status })), Err(err) => Ok(json!({ "closed": true, "restoreStatus": state.restore_status, "saveStatus": state.restore_save_status, "saveError": err })), } } // --------------------------------------------------------------------------- // 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; let result = interaction::click( &mgr.client, &session_id, &state.ref_map, selector, button, click_count, &state.iframe_sessions, ) .await?; if result.dialog_opened { state.pending_pointer_release = result.pending_release; return Ok(json!({ "clicked": selector, "dialogOpened": true })); } 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")?; let result = interaction::dblclick( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; if result.dialog_opened { state.pending_pointer_release = result.pending_release; return Ok(json!({ "clicked": selector, "dialogOpened": true })); } 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(); 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 })) } async fn handle_press(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 key = cmd .get("key") .and_then(|v| v.as_str()) .ok_or("Missing 'key' parameter")?; // Parse modifier+key chords like "Control+a", "Shift+Enter", "Control+Shift+a" let (actual_key, modifiers) = parse_key_chord(key); interaction::press_key_with_modifiers(&mgr.client, &session_id, &actual_key, modifiers).await?; Ok(json!({ "pressed": key })) } /// Parse a key chord string like "Control+a" or "Control+Shift+Enter" into /// the actual key name and an optional CDP modifier bitmask. /// /// CDP modifier values: 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift. fn parse_key_chord(input: &str) -> (String, Option) { let parts: Vec<&str> = input.split('+').collect(); if parts.len() < 2 { return (input.to_string(), None); } let mut modifiers = 0i32; let mut key_parts: Vec<&str> = Vec::new(); for part in &parts { match part.to_lowercase().as_str() { "alt" => modifiers |= 1, "control" | "ctrl" => modifiers |= 2, "meta" | "cmd" | "command" => modifiers |= 4, "shift" => modifiers |= 8, _ => key_parts.push(part), } } // If no modifiers were found, the '+' was part of the key name (e.g. "+") // or the input was something unexpected — treat the whole string as the key. if modifiers == 0 { return (input.to_string(), None); } // The actual key is whatever remains after stripping modifiers. // If nothing remains (e.g. "Control+"), treat the whole string as-is. let actual_key = if key_parts.is_empty() { input.to_string() } else { key_parts.join("+") }; (actual_key, Some(modifiers)) } async fn handle_hover(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::hover( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "hovered": selector })) } async fn handle_scroll(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()); let (mut dx, mut dy) = ( cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0), ); if let Some(direction) = cmd.get("direction").and_then(|v| v.as_str()) { let amount = cmd.get("amount").and_then(|v| v.as_f64()).unwrap_or(300.0); match direction { "up" => dy = -amount, "down" => dy = amount, "left" => dx = -amount, "right" => dx = amount, _ => {} } } interaction::scroll( &mgr.client, &session_id, &state.ref_map, selector, dx, dy, &state.iframe_sessions, ) .await?; Ok(json!({ "scrolled": true })) } async fn handle_select(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")?; let values: Vec = match cmd.get("values") { Some(Value::Array(arr)) => arr .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(), Some(Value::String(s)) => vec![s.clone()], _ => cmd .get("value") .and_then(|v| v.as_str()) .map(|s| vec![s.to_string()]) .unwrap_or_default(), }; interaction::select_option( &mgr.client, &session_id, &state.ref_map, selector, &values, &state.iframe_sessions, ) .await?; Ok(json!({ "selected": values })) } async fn handle_check(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::check( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "checked": selector })) } async fn handle_uncheck(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::uncheck( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "unchecked": selector })) } async fn handle_wait(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 timeout_ms = state.timeout_ms(cmd); if let Some(text) = cmd.get("text").and_then(|v| v.as_str()) { wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?; return Ok(json!({ "waited": "text", "text": text })); } if let Some(selector) = cmd.get("selector").and_then(|v| v.as_str()) { let state_str = cmd .get("state") .and_then(|v| v.as_str()) .unwrap_or("visible"); // Honor an active `frame ` selection, like element resolution does. match state.active_frame_id.as_deref() { Some(frame_id) => match state.iframe_sessions.get(frame_id) { Some(frame_session) => { wait_for_selector(&mgr.client, frame_session, selector, state_str, timeout_ms) .await? } None => { wait_for_selector_in_frame( &mgr.client, &session_id, frame_id, selector, state_str, timeout_ms, ) .await? } }, None => { wait_for_selector(&mgr.client, &session_id, selector, state_str, timeout_ms).await? } } return Ok(json!({ "waited": "selector", "selector": selector })); } if let Some(url_pattern) = cmd.get("url").and_then(|v| v.as_str()) { wait_for_url(mgr, url_pattern, timeout_ms).await?; return Ok(json!({ "waited": "url", "url": url_pattern })); } if let Some(fn_str) = cmd.get("function").and_then(|v| v.as_str()) { wait_for_function(&mgr.client, &session_id, fn_str, timeout_ms).await?; return Ok(json!({ "waited": "function" })); } if let Some(load_state) = cmd.get("loadState").and_then(|v| v.as_str()) { let wait_until = WaitUntil::from_str(load_state); mgr.wait_for_lifecycle_external(wait_until, &session_id) .await?; return Ok(json!({ "waited": "load", "state": load_state })); } // Just a timeout wait tokio::time::sleep(tokio::time::Duration::from_millis(timeout_ms)).await; Ok(json!({ "waited": "timeout", "ms": timeout_ms })) } async fn handle_gettext(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")?; let text = super::element::get_element_text( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "text": text, "origin": url })) } async fn handle_getattribute(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")?; let attribute = cmd .get("attribute") .and_then(|v| v.as_str()) .ok_or("Missing 'attribute' parameter")?; let value = super::element::get_element_attribute( &mgr.client, &session_id, &state.ref_map, selector, attribute, &state.iframe_sessions, ) .await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "value": value, "origin": url })) } async fn handle_isvisible(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")?; let visible = super::element::is_element_visible( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "visible": visible, "origin": url })) } async fn handle_isenabled(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")?; let enabled = super::element::is_element_enabled( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "enabled": enabled, "origin": url })) } async fn handle_ischecked(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")?; let checked = super::element::is_element_checked( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "checked": checked, "origin": url })) } async fn handle_back(state: &mut DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.back().await?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let url = wb.get_url().await.unwrap_or_default(); state.ref_map.clear(); return Ok(json!({ "url": url })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; mgr.evaluate("history.back()", None).await?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let url = mgr.get_url().await.unwrap_or_default(); state.ref_map.clear(); Ok(json!({ "url": url })) } async fn handle_forward(state: &mut DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.forward().await?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let url = wb.get_url().await.unwrap_or_default(); state.ref_map.clear(); return Ok(json!({ "url": url })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; mgr.evaluate("history.forward()", None).await?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let url = mgr.get_url().await.unwrap_or_default(); state.ref_map.clear(); Ok(json!({ "url": url })) } async fn handle_reload(state: &mut DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.reload().await?; tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; let url = wb.get_url().await.unwrap_or_default(); state.ref_map.clear(); return Ok(json!({ "url": url })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); mgr.client .send_command_no_params("Page.reload", Some(&session_id)) .await?; let mut rx = mgr.client.subscribe(); let _ = tokio::time::timeout(tokio::time::Duration::from_secs(10), async { loop { match rx.recv().await { Ok(event) => { if event.method == "Page.loadEventFired" && event.session_id.as_deref() == Some(&session_id) { return; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(_) => break, } } }) .await; let url = mgr.get_url().await.unwrap_or_default(); state.ref_map.clear(); Ok(json!({ "url": url })) } // --------------------------------------------------------------------------- // Wait helpers // --------------------------------------------------------------------------- async fn wait_for_selector( client: &super::cdp::client::CdpClient, session_id: &str, selector: &str, state: &str, timeout_ms: u64, ) -> Result<(), String> { let check_fn = match state { "attached" => format!( "!!document.querySelector({})", serde_json::to_string(selector).unwrap_or_default() ), "detached" => format!( "!document.querySelector({})", serde_json::to_string(selector).unwrap_or_default() ), "hidden" => format!( r#"(() => {{ const el = document.querySelector({sel}); if (!el) return true; const s = window.getComputedStyle(el); return s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0; }})()"#, sel = serde_json::to_string(selector).unwrap_or_default() ), _ => format!( r#"(() => {{ const el = document.querySelector({sel}); if (!el) return false; const r = el.getBoundingClientRect(); const s = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; }})()"#, sel = serde_json::to_string(selector).unwrap_or_default() ), }; poll_until_true(client, session_id, &check_fn, timeout_ms).await } async fn wait_for_url(mgr: &BrowserManager, pattern: &str, timeout_ms: u64) -> Result<(), String> { let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); loop { let url = mgr.get_url().await?; if route_url_matches(pattern, &url) { return Ok(()); } if tokio::time::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } } async fn wait_for_text( client: &super::cdp::client::CdpClient, session_id: &str, text: &str, timeout_ms: u64, ) -> Result<(), String> { let check_fn = format!( "(document.body.innerText || '').includes({})", serde_json::to_string(text).unwrap_or_default() ); poll_until_true(client, session_id, &check_fn, timeout_ms).await } async fn wait_for_function( client: &super::cdp::client::CdpClient, session_id: &str, fn_str: &str, timeout_ms: u64, ) -> Result<(), String> { let check_fn = format!("!!({})", fn_str); poll_until_true(client, session_id, &check_fn, timeout_ms).await } /// wait_for_selector inside a same-process iframe selected via `frame `: /// polls through the owner element's contentDocument, which stays correct /// even if the frame navigates (the getter re-resolves every poll). async fn wait_for_selector_in_frame( client: &super::cdp::client::CdpClient, session_id: &str, frame_id: &str, selector: &str, state: &str, timeout_ms: u64, ) -> Result<(), String> { let owner_object_id = super::element::frame_owner_object_id(client, session_id, frame_id).await?; let sel = serde_json::to_string(selector).unwrap_or_default(); let check = match state { "attached" => format!("!!doc.querySelector({sel})"), "detached" => format!("!doc.querySelector({sel})"), "hidden" => format!( r#"(() => {{ const el = doc.querySelector({sel}); if (!el) return true; const s = doc.defaultView.getComputedStyle(el); return s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0; }})()"#, ), _ => format!( r#"(() => {{ const el = doc.querySelector({sel}); if (!el) return false; const r = el.getBoundingClientRect(); const s = doc.defaultView.getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; }})()"#, ), }; let function = format!( "function() {{ const doc = this.contentDocument; if (!doc) return false; return {check}; }}", ); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); loop { let result = client .send_command( "Runtime.callFunctionOn", Some(json!({ "objectId": owner_object_id, "functionDeclaration": function, "returnByValue": true, })), Some(session_id), ) .await?; let satisfied = result .get("result") .and_then(|r| r.get("value")) .and_then(|v| v.as_bool()) .unwrap_or(false); if satisfied { return Ok(()); } if tokio::time::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } } async fn poll_until_true( client: &super::cdp::client::CdpClient, session_id: &str, expression: &str, timeout_ms: u64, ) -> Result<(), String> { let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); loop { let result: super::cdp::types::EvaluateResult = client .send_command_typed( "Runtime.evaluate", &super::cdp::types::EvaluateParams { expression: expression.to_string(), return_by_value: Some(true), await_promise: Some(true), }, Some(session_id), ) .await?; if result .result .value .as_ref() .and_then(|v| v.as_bool()) .unwrap_or(false) { return Ok(()); } if tokio::time::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } } // --------------------------------------------------------------------------- // Phase 3 handlers // --------------------------------------------------------------------------- async fn handle_cookies_get(cmd: &Value, state: &DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let cookies_list = wb.get_cookies().await?; return Ok(json!({ "cookies": cookies_list })); } } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let urls = cmd.get("urls").and_then(|v| v.as_array()).map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }); let cookies_list = cookies::get_cookies(&mgr.client, &session_id, urls).await?; Ok(json!({ "cookies": cookies_list })) } async fn handle_cookies_set(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let url = mgr.get_url().await.ok(); let cookie_values = if let Some(arr) = cmd.get("cookies").and_then(|v| v.as_array()) { arr.clone() } else { let mut cookie = serde_json::Map::new(); for key in &[ "name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite", "url", ] { if let Some(v) = cmd.get(*key) { if !v.is_null() { cookie.insert(key.to_string(), v.clone()); } } } vec![Value::Object(cookie)] }; cookies::set_cookies(&mgr.client, &session_id, cookie_values, url.as_deref()).await?; Ok(json!({ "set": true })) } async fn handle_cookies_clear(state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); cookies::clear_cookies(&mgr.client, &session_id).await?; Ok(json!({ "cleared": true })) } async fn handle_storage_get(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let storage_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("local"); let key = cmd.get("key").and_then(|v| v.as_str()); storage::storage_get(&mgr.client, &session_id, storage_type, key).await } async fn handle_storage_set(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let storage_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("local"); let key = cmd .get("key") .and_then(|v| v.as_str()) .ok_or("Missing 'key' parameter")?; let value = cmd .get("value") .and_then(|v| v.as_str()) .ok_or("Missing 'value' parameter")?; storage::storage_set(&mgr.client, &session_id, storage_type, key, value).await?; Ok(json!({ "set": true })) } async fn handle_storage_clear(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let storage_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("local"); storage::storage_clear(&mgr.client, &session_id, storage_type).await?; Ok(json!({ "cleared": true })) } async fn handle_setcontent(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let html = cmd .get("html") .and_then(|v| v.as_str()) .ok_or("Missing 'html' parameter")?; network::set_content(&mgr.client, &session_id, html).await?; Ok(json!({ "set": true })) } async fn handle_headers(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let headers_value = cmd.get("headers").ok_or("Missing 'headers' parameter")?; let headers: HashMap = headers_value .as_object() .map(|m| { m.iter() .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) .collect() }) .unwrap_or_default(); network::set_extra_headers(&mgr.client, &session_id, &headers).await?; Ok(json!({ "set": true })) } async fn handle_offline(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let offline = cmd.get("offline").and_then(|v| v.as_bool()).unwrap_or(true); network::set_offline(&mgr.client, &session_id, offline).await?; Ok(json!({ "offline": offline })) } async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result { let clear = cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false); if clear { state.event_tracker.clear_console(); Ok(json!({ "cleared": true })) } else { let result = state.event_tracker.get_console_json(); Ok(result) } } async fn handle_errors(state: &DaemonState) -> Result { Ok(state.event_tracker.get_errors_json()) } async fn handle_session_info(state: &DaemonState) -> Result { Ok(json!({ "session": state.session_id, "namespace": env::var("AGENT_BROWSER_NAMESPACE").ok(), "socketDir": get_socket_dir().to_string_lossy(), "backgroundPid": std::process::id(), "browserLaunched": state.browser.is_some(), "pageCount": state.browser.as_ref().map(|mgr| mgr.page_count()).unwrap_or(0), "engine": state.engine, "launchHash": state.launch_hash, "compatibilityStatus": "current", "effectiveLaunch": { "browserLaunched": state.browser.is_some(), "engine": state.engine, "launchHash": state.launch_hash, }, "restoreKey": state.session_name, "restoreStatus": state.restore_status, "restoreStatusDetail": state.restore_status_detail, "restoreLoadedPath": state.restore_loaded_path, "restoreValidationPending": state.restore_validation_pending, "restoreSave": state.restore_save, "saveStatus": state.restore_save_status, "restoreSavedPath": state.restore_saved_path, "restoreCheckUrl": state.restore_check_url, "restoreCheckText": state.restore_check_text, "restoreCheckFn": state.restore_check_fn, })) } async fn handle_state_save(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let path = cmd.get("path").and_then(|v| v.as_str()); let saved_path = state::save_state( &mgr.client, &session_id, path, state.session_name.as_deref(), &state.session_id, mgr.visited_origins(), ) .await?; Ok(json!({ "saved": true, "path": saved_path })) } async fn handle_state_load(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 path = cmd .get("path") .and_then(|v| v.as_str()) .ok_or("Missing 'path' parameter")?; state::load_state(&mgr.client, &session_id, path).await?; mark_explicit_storage_state_loaded(state, path); Ok(json!({ "loaded": true, "path": path })) } // --------------------------------------------------------------------------- // Phase 6 handlers // --------------------------------------------------------------------------- async fn handle_diff_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 compact = cmd .get("compact") .and_then(|v| v.as_bool()) .unwrap_or(false); let max_depth = cmd .get("maxDepth") .and_then(|v| v.as_u64()) .map(|d| d as usize); let selector = cmd .get("selector") .and_then(|v| v.as_str()) .map(String::from); let options = SnapshotOptions { compact, depth: max_depth, selector, ..SnapshotOptions::default() }; let current = snapshot::take_snapshot( &mgr.client, &session_id, &options, &mut state.ref_map, state.active_frame_id.as_deref(), &state.iframe_sessions, ) .await?; let baseline = cmd.get("baseline").and_then(|v| v.as_str()); let baseline_text = match baseline { Some(b) if std::path::Path::new(b).exists() => { std::fs::read_to_string(b).map_err(|e| format!("Failed to read baseline: {}", e))? } Some(b) => b.to_string(), None => String::new(), }; let result = diff::diff_snapshots(&baseline_text, ¤t); Ok(json!({ "diff": result.diff, "additions": result.additions, "removals": result.removals, "unchanged": result.unchanged, "changed": result.changed, })) } async fn handle_diff_url(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let url1 = cmd .get("url1") .and_then(|v| v.as_str()) .ok_or("Missing 'url1' parameter")?; let url2 = cmd .get("url2") .and_then(|v| v.as_str()) .ok_or("Missing 'url2' parameter")?; let wait_until = cmd .get("waitUntil") .and_then(|v| v.as_str()) .map(WaitUntil::from_str) .unwrap_or(WaitUntil::Load); // Navigate to URL1 and snapshot mgr.navigate(url1, wait_until).await?; let session_id = mgr.active_session_id()?.to_string(); let options = SnapshotOptions::default(); let snap1 = snapshot::take_snapshot( &mgr.client, &session_id, &options, &mut state.ref_map, None, &state.iframe_sessions, ) .await?; // Navigate to URL2 and snapshot mgr.navigate(url2, wait_until).await?; state.ref_map.clear(); let snap2 = snapshot::take_snapshot( &mgr.client, &session_id, &options, &mut state.ref_map, None, &state.iframe_sessions, ) .await?; let result = diff::diff_text(&snap1, &snap2); Ok(json!({ "diff": result, "url1": url1, "url2": url2, "snapshot1": snap1, "snapshot2": snap2, })) } async fn handle_credentials_set(cmd: &Value) -> Result { let name = cmd .get("name") .and_then(|v| v.as_str()) .ok_or("Missing 'name'")?; let username = cmd .get("username") .and_then(|v| v.as_str()) .ok_or("Missing 'username'")?; let password = cmd .get("password") .and_then(|v| v.as_str()) .ok_or("Missing 'password'")?; let url = cmd.get("url").and_then(|v| v.as_str()); auth::credentials_set(name, username, password, url) } async fn handle_credentials_get(cmd: &Value) -> Result { let name = cmd .get("name") .and_then(|v| v.as_str()) .ok_or("Missing 'name'")?; auth::credentials_get(name) } async fn handle_credentials_delete(cmd: &Value) -> Result { let name = cmd .get("name") .and_then(|v| v.as_str()) .ok_or("Missing 'name'")?; auth::credentials_delete(name) } async fn handle_credentials_list() -> Result { auth::credentials_list() } async fn handle_auth_show(cmd: &Value) -> Result { let name = cmd .get("name") .and_then(|v| v.as_str()) .ok_or("Missing 'name'")?; auth::auth_show(name) } async fn handle_mouse(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let event_type = cmd .get("eventType") .and_then(|v| v.as_str()) .unwrap_or("mouseMoved"); let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("none"); let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0); mgr.client .send_command( "Input.dispatchMouseEvent", Some(json!({ "type": event_type, "x": x, "y": y, "button": button, "clickCount": click_count, })), Some(&session_id), ) .await?; Ok(json!({ "dispatched": event_type })) } async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); match cmd.get("subaction").and_then(|v| v.as_str()) { Some("type") => { 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 })); } Some("insertText") => { let text = cmd .get("text") .and_then(|v| v.as_str()) .ok_or("Missing 'text' parameter")?; mgr.client .send_command( "Input.insertText", Some(json!({ "text": text })), Some(&session_id), ) .await?; return Ok(json!({ "inserted": true })); } _ => {} } let event_type = cmd .get("eventType") .and_then(|v| v.as_str()) .unwrap_or("keyDown"); let key = cmd.get("key").and_then(|v| v.as_str()); let code = cmd.get("code").and_then(|v| v.as_str()); let text = cmd.get("text").and_then(|v| v.as_str()); let mut params = json!({ "type": event_type }); if let Some(k) = key { params["key"] = Value::String(k.to_string()); } if let Some(c) = code { params["code"] = Value::String(c.to_string()); } if let Some(t) = text { params["text"] = Value::String(t.to_string()); } mgr.client .send_command("Input.dispatchKeyEvent", Some(params), Some(&session_id)) .await?; Ok(json!({ "dispatched": event_type })) } // --------------------------------------------------------------------------- // Phase 5 handlers // --------------------------------------------------------------------------- async fn handle_tab_list(state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let tabs = mgr.tab_list(); Ok(json!({ "tabs": tabs })) } async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let url = cmd.get("url").and_then(|v| v.as_str()); let label = cmd.get("label").and_then(|v| v.as_str()); state.ref_map.clear(); state.iframe_sessions.clear(); state.active_frame_id = None; mgr.tab_new(url, label).await } async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let tab_ref_str = cmd .get("tabId") .and_then(|v| v.as_str()) .ok_or("Missing 'tabId' parameter (expected `t` or a label)")?; let tab_ref = super::browser::TabRef::parse(tab_ref_str)?; let tab_id = mgr.resolve_tab_ref(&tab_ref)?; state.ref_map.clear(); state.iframe_sessions.clear(); state.active_frame_id = None; let result = mgr.tab_switch_by_id(tab_id).await?; if let Some(ref server) = state.stream_server { if let Ok(dims) = mgr .evaluate( "JSON.stringify([window.innerWidth,window.innerHeight])", None, ) .await { if let Some(s) = dims.get("result").and_then(|v| v.as_str()) { if let Ok(arr) = serde_json::from_str::>(s) { if arr.len() == 2 && arr[0] > 0 && arr[1] > 0 { server.set_viewport(arr[0], arr[1]).await; } } } } } Ok(result) } async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let tab_id = match cmd.get("tabId").and_then(|v| v.as_str()) { Some(s) => { let tab_ref = super::browser::TabRef::parse(s)?; Some(mgr.resolve_tab_ref(&tab_ref)?) } None => None, }; state.ref_map.clear(); state.iframe_sessions.clear(); state.active_frame_id = None; mgr.tab_close_by_id(tab_id).await } async fn handle_viewport(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let width = cmd.get("width").and_then(|v| v.as_i64()).unwrap_or(1280) as i32; let height = cmd.get("height").and_then(|v| v.as_i64()).unwrap_or(720) as i32; let scale = cmd .get("deviceScaleFactor") .and_then(|v| v.as_f64()) .unwrap_or(1.0); let mobile = cmd.get("mobile").and_then(|v| v.as_bool()).unwrap_or(false); mgr.set_viewport(width, height, scale, mobile).await?; state.viewport = Some((width, height, scale, mobile)); // Update stream server viewport so status messages and screencast use the new dimensions if let Some(ref server) = state.stream_server { server.set_viewport(width as u32, height as u32).await; } Ok(json!({ "width": width, "height": height, "deviceScaleFactor": scale, "mobile": mobile })) } async fn handle_user_agent(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let ua = cmd .get("userAgent") .and_then(|v| v.as_str()) .ok_or("Missing 'userAgent' parameter")?; mgr.set_user_agent(ua).await?; Ok(json!({ "userAgent": ua })) } async fn handle_set_media(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let media = cmd.get("media").and_then(|v| v.as_str()); let mut feat_list: Vec<(String, String)> = Vec::new(); if let Some(scheme) = cmd.get("colorScheme").and_then(|v| v.as_str()) { feat_list.push(("prefers-color-scheme".to_string(), scheme.to_string())); } if let Some(motion) = cmd.get("reducedMotion").and_then(|v| v.as_str()) { feat_list.push(("prefers-reduced-motion".to_string(), motion.to_string())); } if let Some(obj) = cmd.get("features").and_then(|v| v.as_object()) { for (k, v) in obj { feat_list.push((k.clone(), v.as_str().unwrap_or("").to_string())); } } let features = if feat_list.is_empty() { None } else { Some(feat_list) }; mgr.set_emulated_media(media, features).await?; Ok(json!({ "set": true })) } async fn handle_download(cmd: &Value, state: &mut DaemonState) -> Result { let selector = cmd .get("selector") .and_then(|v| v.as_str()) .ok_or("Missing 'selector' parameter")?; let path_str = cmd .get("path") .and_then(|v| v.as_str()) .ok_or("Missing 'path' parameter")?; // Resolve to absolute path and canonicalize to prevent path traversal let raw_dest = if std::path::Path::new(path_str).is_absolute() { PathBuf::from(path_str) } else { std::env::current_dir() .map_err(|e| format!("Failed to get current directory: {}", e))? .join(path_str) }; // Extract directory and desired filename let download_dir = raw_dest .parent() .ok_or("Invalid download path: no parent directory")? .to_path_buf(); // Create the directory if it doesn't exist std::fs::create_dir_all(&download_dir) .map_err(|e| format!("Failed to create download directory: {}", e))?; // Canonicalize after mkdir so the path actually exists for resolution let download_dir = download_dir .canonicalize() .map_err(|e| format!("Failed to resolve download directory: {}", e))?; let dest = download_dir.join( raw_dest .file_name() .ok_or("Invalid download path: no filename")?, ); let download_dir_str = download_dir .to_str() .ok_or("Download directory path is not valid UTF-8")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); // Set download behavior to save to the parent directory mgr.set_download_behavior(download_dir_str).await?; // Subscribe to CDP events before clicking so we don't miss the download event let mut rx = mgr.client.subscribe(); // Click the element to trigger the download interaction::click( &mgr.client, &session_id, &state.ref_map, selector, "left", 1, &state.iframe_sessions, ) .await?; // Wait for download to complete const DOWNLOAD_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(30); let deadline = tokio::time::Instant::now() + DOWNLOAD_TIMEOUT; let mut downloaded_guid: Option = None; loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return Err("Timeout waiting for download to complete".to_string()); } match tokio::time::timeout(remaining, rx.recv()).await { Ok(Ok(event)) => { // Browser-domain download events may arrive without a sessionId // or with a different sessionId than the page session, so we // accept them regardless. Page-domain events are matched by // session to avoid cross-tab confusion. let is_page_session = event.session_id.as_deref() == Some(&session_id); let is_download_event = |method: &str, browser_method: &str, page_method: &str| { method == browser_method || (method == page_method && is_page_session) }; // Capture the GUID from downloadWillBegin if is_download_event( &event.method, "Browser.downloadWillBegin", "Page.downloadWillBegin", ) { if let Some(guid) = event.params.get("guid").and_then(|v| v.as_str()) { downloaded_guid = Some(guid.to_string()); } } // Check for download completion or cancellation if is_download_event( &event.method, "Browser.downloadProgress", "Page.downloadProgress", ) { match event.params.get("state").and_then(|v| v.as_str()) { Some("completed") => break, Some("canceled") => { return Err("Download was canceled".to_string()); } _ => {} } } } Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, Ok(Err(_)) => return Err("Event stream closed".to_string()), Err(_) => return Err("Timeout waiting for download to complete".to_string()), } } // With "allowAndName" behavior, Chrome saves the file using the GUID as filename. // Rename it to the user-requested filename. if let Some(guid) = downloaded_guid { let guid_path = download_dir.join(&guid); // Chrome may still be flushing the file to disk after signalling // completion; wait briefly for it to appear. for _ in 0..10 { if guid_path.exists() { break; } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } if guid_path.exists() { std::fs::rename(&guid_path, &dest) .map_err(|e| format!("Failed to rename downloaded file: {}", e))?; } else { // The file might have been saved under its original name instead // of the GUID (e.g. when Chrome falls back to "allow" behavior). if !dest.exists() { return Err(format!( "Downloaded file not found at expected path (GUID: {})", guid )); } } } else { // GUID capture failed -- the file may have been saved under its original name // by Chrome. Only return success if dest already exists (avoid touching // unrelated files in the directory). if !dest.exists() { return Err( "Download completed but could not determine the downloaded file name".to_string(), ); } } let dest_str = dest.to_string_lossy().to_string(); Ok(json!({ "path": dest_str })) } // --------------------------------------------------------------------------- // Phase 4 handlers // --------------------------------------------------------------------------- async fn handle_trace_start(state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); native_tracing::trace_start(&mgr.client, &session_id, &mut state.tracing_state).await } async fn handle_trace_stop(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 path = cmd.get("path").and_then(|v| v.as_str()); native_tracing::trace_stop(&mgr.client, &session_id, &mut state.tracing_state, path).await } async fn handle_profiler_start(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 categories = cmd.get("categories").and_then(|v| v.as_array()).map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }); native_tracing::profiler_start( &mgr.client, &session_id, &mut state.tracing_state, categories, ) .await } async fn handle_profiler_stop(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 path = cmd.get("path").and_then(|v| v.as_str()); native_tracing::profiler_stop(&mgr.client, &session_id, &mut state.tracing_state, path).await } async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result { let path = cmd .get("path") .and_then(|v| v.as_str()) .ok_or("Missing 'path' parameter")?; let recording_url = cmd .get("url") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()); let viewport = state.viewport; let (client, new_session_id) = { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let old_session_id = mgr.active_session_id()?.to_string(); // Capture current URL if no URL specified let nav_url = if let Some(u) = recording_url { u.to_string() } else { mgr.get_url() .await .unwrap_or_else(|_| "about:blank".to_string()) }; // Capture current cookies let cookies_result = mgr .client .send_command_no_params("Network.getAllCookies", Some(&old_session_id)) .await .ok(); // Create new browser context let ctx_result = mgr .client .send_command_no_params("Target.createBrowserContext", None) .await?; let context_id = ctx_result .get("browserContextId") .and_then(|v| v.as_str()) .ok_or("Failed to get browserContextId")? .to_string(); // Create page in new context let create_result: CreateTargetResult = mgr .client .send_command_typed( "Target.createTarget", &json!({ "url": "about:blank", "browserContextId": context_id }), None, ) .await?; let attach_result: AttachToTargetResult = mgr .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: create_result.target_id.clone(), flatten: true, }, None, ) .await?; let new_session_id = attach_result.session_id.clone(); mgr.enable_domains_pub(&new_session_id).await?; // Re-apply download behavior to the recording context. // Without this, downloads in the recording context are silently dropped // because Browser.setDownloadBehavior at launch only applies to the default context. if let Some(ref dl_path) = mgr.download_path { let _ = mgr .client .send_command( "Browser.setDownloadBehavior", Some(json!({ "behavior": "allow", "downloadPath": dl_path, "browserContextId": context_id, "eventsEnabled": true })), None, ) .await; } // Re-apply HTTPS error ignore to the recording context. // Security.setIgnoreCertificateErrors at launch only applies to the session it was sent on. if mgr.ignore_https_errors { let _ = mgr .client .send_command( "Security.setIgnoreCertificateErrors", Some(json!({ "ignore": true })), Some(&new_session_id), ) .await; } // Transfer cookies to new context if let Some(ref cr) = cookies_result { if let Some(cookie_arr) = cr.get("cookies").and_then(|v| v.as_array()) { if !cookie_arr.is_empty() { let _ = mgr .client .send_command( "Network.setCookies", Some(json!({ "cookies": cookie_arr })), Some(&new_session_id), ) .await; } } } // Add page and switch to it let tab_id = mgr.assign_tab_id(); mgr.add_page(super::browser::PageInfo { tab_id, label: None, target_id: create_result.target_id, session_id: new_session_id.clone(), url: nav_url.clone(), title: String::new(), target_type: "page".to_string(), }); if let Some((w, h, scale, mobile)) = viewport { let _ = mgr.set_viewport(w, h, scale, mobile).await; } // Navigate to URL if nav_url != "about:blank" { let _ = mgr .client .send_command( "Page.navigate", Some(json!({ "url": nav_url })), Some(&new_session_id), ) .await; tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; } (mgr.client.clone(), new_session_id) }; let result = recording::recording_start(&mut state.recording_state, path)?; state.start_recording_task(client, new_session_id).await?; if let Some(ref server) = state.stream_server { server.set_recording(true, &state.engine).await; } Ok(result) } async fn handle_recording_stop(state: &mut DaemonState) -> Result { state.stop_recording_task().await?; let result = recording::recording_stop(&mut state.recording_state); if let Some(ref server) = state.stream_server { server.set_recording(false, &state.engine).await; } result } async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Result { let path = cmd .get("path") .and_then(|v| v.as_str()) .ok_or("Missing 'path' parameter")?; let recording_url = cmd .get("url") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from); let _ = state.stop_recording_task().await; let previous_path = if state.recording_state.active { recording::recording_stop(&mut state.recording_state) .ok() .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from)) } else { None }; let recording_target = if let Some(ref mut browser) = state.browser { if let Some(url) = recording_url { browser.navigate(&url, WaitUntil::Load).await?; } let session_id = browser.active_session_id()?.to_string(); Some((browser.client.clone(), session_id)) } else { None }; recording::recording_start(&mut state.recording_state, path)?; if let Some((client, session_id)) = recording_target { state.start_recording_task(client, session_id).await?; } Ok(json!({ "restarted": true, "previousPath": previous_path, "path": path, })) } async fn handle_pdf(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let params = json!({ "printBackground": cmd.get("printBackground").and_then(|v| v.as_bool()).unwrap_or(true), "landscape": cmd.get("landscape").and_then(|v| v.as_bool()).unwrap_or(false), "preferCSSPageSize": cmd.get("preferCSSPageSize").and_then(|v| v.as_bool()).unwrap_or(false), }); let result = mgr .client .send_command("Page.printToPDF", Some(params), Some(&session_id)) .await?; let data = result .get("data") .and_then(|v| v.as_str()) .ok_or("No PDF data returned")?; let path = cmd.get("path").and_then(|v| v.as_str()); let save_path = match path { Some(p) => p.to_string(), None => { let dir = dirs::home_dir() .unwrap_or_else(std::env::temp_dir) .join(".agent-browser") .join("tmp") .join("pdfs"); let _ = std::fs::create_dir_all(&dir); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); dir.join(format!("page-{}.pdf", timestamp)) .to_string_lossy() .to_string() } }; let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data) .map_err(|e| format!("Failed to decode PDF: {}", e))?; std::fs::write(&save_path, &bytes).map_err(|e| format!("Failed to save PDF: {}", e))?; Ok(json!({ "path": save_path })) } // --------------------------------------------------------------------------- // Phase 8 handlers // --------------------------------------------------------------------------- async fn handle_focus(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::focus( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "focused": selector })) } async fn handle_clear(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::clear( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "cleared": selector })) } async fn handle_selectall(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::select_all( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "selected": selector })) } async fn handle_scrollintoview(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::scroll_into_view( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "scrolled": selector })) } async fn handle_dispatch(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")?; let event_type = cmd .get("event") .or_else(|| cmd.get("eventType")) .and_then(|v| v.as_str()) .ok_or("Missing 'event' parameter")?; let event_init = cmd.get("eventInit"); interaction::dispatch_event( &mgr.client, &session_id, &state.ref_map, selector, event_type, event_init, &state.iframe_sessions, ) .await?; Ok(json!({ "dispatched": event_type, "selector": selector })) } async fn handle_highlight(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::highlight( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "highlighted": selector })) } async fn handle_tap(cmd: &Value, state: &mut DaemonState) -> Result { let selector = cmd.get("selector").and_then(|v| v.as_str()); // Route through Appium for iOS/WebDriver using coordinate-based tap if let Some(ref appium) = state.appium { if state.browser.is_none() { let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(200.0); let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(200.0); appium.tap(x, y).await?; return Ok(json!({ "tapped": true, "x": x, "y": y })); } } let sel = selector.ok_or("Missing 'selector' parameter")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); interaction::tap_touch( &mgr.client, &session_id, &state.ref_map, sel, &state.iframe_sessions, ) .await?; Ok(json!({ "tapped": sel })) } async fn handle_boundingbox(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")?; let bbox = super::element::get_element_bounding_box( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(bbox) } async fn handle_innertext(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")?; let text = super::element::get_element_inner_text( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "text": text })) } async fn handle_innerhtml(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")?; let html = super::element::get_element_inner_html( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "html": html })) } async fn handle_inputvalue(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")?; let value = super::element::get_element_input_value( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "value": value })) } async fn handle_setvalue(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")?; let value = cmd .get("value") .and_then(|v| v.as_str()) .ok_or("Missing 'value' parameter")?; super::element::set_element_value( &mgr.client, &session_id, &state.ref_map, selector, value, &state.iframe_sessions, ) .await?; Ok(json!({ "set": selector, "value": value })) } async fn handle_count(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")?; let count = super::element::get_element_count(&mgr.client, &session_id, selector).await?; Ok(json!({ "count": count, "selector": selector })) } async fn handle_styles(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")?; let properties = cmd.get("properties").and_then(|v| v.as_array()).map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }); let styles = super::element::get_element_styles( &mgr.client, &session_id, &state.ref_map, selector, properties, &state.iframe_sessions, ) .await?; Ok(json!({ "styles": styles })) } async fn handle_bringtofront(state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; mgr.bring_to_front().await?; Ok(json!({ "broughtToFront": true })) } async fn handle_timezone(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let timezone = cmd .get("timezoneId") .or_else(|| cmd.get("timezone")) .and_then(|v| v.as_str()) .ok_or("Missing 'timezoneId' parameter")?; mgr.set_timezone(timezone).await?; Ok(json!({ "timezoneId": timezone })) } async fn handle_locale(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let locale = cmd .get("locale") .and_then(|v| v.as_str()) .ok_or("Missing 'locale' parameter")?; mgr.set_locale(locale).await?; Ok(json!({ "locale": locale })) } async fn handle_geolocation(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let latitude = cmd .get("latitude") .and_then(|v| v.as_f64()) .ok_or("Missing 'latitude' parameter")?; let longitude = cmd .get("longitude") .and_then(|v| v.as_f64()) .ok_or("Missing 'longitude' parameter")?; let accuracy = cmd.get("accuracy").and_then(|v| v.as_f64()); mgr.set_geolocation(latitude, longitude, accuracy).await?; Ok(json!({ "latitude": latitude, "longitude": longitude })) } async fn handle_permissions(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let permissions: Vec = cmd .get("permissions") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }) .unwrap_or_default(); mgr.grant_permissions(&permissions).await?; Ok(json!({ "granted": permissions })) } async fn handle_dialog(cmd: &Value, state: &mut DaemonState) -> Result { let response = cmd.get("response").and_then(|v| v.as_str()); // dialog status — return pending dialog info if response == Some("status") { return Ok(match &state.pending_dialog { Some(dialog) => { let mut obj = json!({ "hasDialog": true, "type": dialog.dialog_type, "message": dialog.message, }); if let Some(ref prompt) = dialog.default_prompt { obj["defaultPrompt"] = json!(prompt); } obj } None => json!({ "hasDialog": false }), }); } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let accept = response .map(|r| r == "accept") .or_else(|| cmd.get("accept").and_then(|v| v.as_bool())) .unwrap_or(true); let prompt_text = cmd.get("promptText").and_then(|v| v.as_str()); // Clear tracked state even if Chrome reports no dialog (e.g. it was // already resolved and the closed event was missed); otherwise a stale // pending_dialog would make every page command fail fast forever. let result = mgr.handle_dialog(accept, prompt_text).await; state.pending_dialog = None; result?; // If a click's mousedown opened this dialog, the button is still logically // down. Release it now that the page is unblocked so the next click does // not register as a drag or double-click. if let Some(release) = state.pending_pointer_release.take() { if let Some(ref mgr) = state.browser { let _ = interaction::dispatch_pending_release(&mgr.client, &release).await; } } Ok(json!({ "handled": true, "accepted": accept })) } async fn handle_upload(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let selector = cmd .get("selector") .and_then(|v| v.as_str()) .ok_or("Missing 'selector' parameter")?; let files: Vec = cmd .get("files") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }) .or_else(|| { cmd.get("file") .and_then(|v| v.as_str()) .map(|s| vec![s.to_string()]) }) .unwrap_or_default(); mgr.upload_files(selector, &files, &state.ref_map, &state.iframe_sessions) .await?; Ok(json!({ "uploaded": files.len(), "selector": selector })) } async fn handle_addscript(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let content = cmd .get("content") .or_else(|| cmd.get("source")) .or_else(|| cmd.get("script")) .and_then(|v| v.as_str()); let url = cmd.get("url").and_then(|v| v.as_str()); if content.is_none() && url.is_none() { return Err("At least one of 'content' or 'url' is required".to_string()); } if let Some(src_url) = url { let js = format!( r#"new Promise((resolve, reject) => {{ const s = document.createElement('script'); s.src = {}; s.onload = () => resolve(true); s.onerror = () => reject(new Error('Failed to load script')); document.head.appendChild(s); }})"#, serde_json::to_string(src_url).unwrap_or_default() ); mgr.evaluate(&js, None).await?; } else if let Some(source) = content { let js = format!( r#"(() => {{ const s = document.createElement('script'); s.textContent = {}; document.head.appendChild(s); }})()"#, serde_json::to_string(source).unwrap_or_default() ); mgr.evaluate(&js, None).await?; } Ok(json!({ "added": true })) } async fn handle_addinitscript(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let source = cmd .get("script") .or_else(|| cmd.get("source")) .or_else(|| cmd.get("content")) .and_then(|v| v.as_str()) .ok_or("Missing 'script' parameter")?; let identifier = mgr.add_script_to_evaluate(source).await?; Ok(json!({ "added": true, "identifier": identifier })) } async fn handle_removeinitscript(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let identifier = cmd .get("identifier") .and_then(|v| v.as_str()) .ok_or("Missing 'identifier' parameter")?; mgr.remove_script_to_evaluate(identifier).await?; Ok(json!({ "removed": true, "identifier": identifier })) } // === React / Web primitives === /// Parse a `Runtime.evaluate` result whose expression returned a JSON string. /// Returns a helpful error if parsing fails. fn parse_json_string(value: Value, what: &str) -> Result { let s = value .as_str() .ok_or_else(|| format!("{} returned non-string value", what))?; serde_json::from_str(s).map_err(|e| format!("{} returned invalid JSON: {}", what, e)) } async fn handle_react_tree(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let script = react::scripts::TREE_SNAPSHOT.replace("{{PICK_RI}}", react::scripts::PICK_REACT_RENDERER); let result = mgr.evaluate(&script, None).await?; let nodes_json = parse_json_string(result, "react tree")?; let nodes: Vec = serde_json::from_value(nodes_json) .map_err(|e| format!("Failed to parse tree nodes: {}", e))?; let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false); if return_json { let nodes_value: Vec = nodes .iter() .map(|n| { json!({ "id": n.id, "type": n.node_type, "name": n.name, "key": n.key, "parent": n.parent, }) }) .collect(); Ok(json!({ "nodes": nodes_value })) } else { Ok(json!({ "tree": react::format_tree(&nodes) })) } } async fn handle_react_inspect(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let fiber_id = cmd .get("fiberId") .and_then(|v| v.as_i64()) .ok_or("Missing 'fiberId' parameter (numeric React fiber id)")?; let script = react::scripts::TREE_INSPECT .replace("{{ID}}", &fiber_id.to_string()) .replace("{{PICK_RI}}", react::scripts::PICK_REACT_RENDERER); let result = mgr.evaluate(&script, None).await?; let parsed = parse_json_string(result, "react inspect")?; Ok(parsed) } async fn handle_react_renders_start(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; // Install for future navigations, then evaluate immediately so the // current page starts recording without a reload. let identifier = mgr .add_script_to_evaluate(react::scripts::RENDERS_INIT) .await?; mgr.evaluate(react::scripts::RENDERS_INIT, None).await?; let _ = cmd; Ok(json!({ "recording": true, "identifier": identifier, "message": "recording renders - interact with the page, then run `react renders stop`" })) } async fn handle_react_renders_stop(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let result = mgr.evaluate(react::scripts::RENDERS_STOP, None).await?; let data_json = parse_json_string(result, "react renders stop")?; let data: react::RendersData = serde_json::from_value(data_json.clone()) .map_err(|e| format!("Failed to parse renders data: {}", e))?; let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false); if return_json { Ok(data_json) } else { Ok(json!({ "report": react::format_renders_report(&data) })) } } async fn handle_react_suspense(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let script = react::scripts::SUSPENSE_WALK.replace("{{PICK_RI}}", react::scripts::PICK_REACT_RENDERER); let result = mgr.evaluate(&script, None).await?; let boundaries_json = parse_json_string(result, "react suspense")?; let boundaries: Vec = serde_json::from_value(boundaries_json.clone()) .map_err(|e| format!("Failed to parse suspense boundaries: {}", e))?; let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false); let only_dynamic = cmd .get("onlyDynamic") .and_then(|v| v.as_bool()) .unwrap_or(false); if return_json { // When only-dynamic is set, filter the JSON payload too so callers // get consistent output regardless of format choice. if only_dynamic { let filtered: Vec<&react::Boundary> = boundaries .iter() .filter(|b| { b.parent_id != 0 && (b.is_suspended || !b.suspended_by.is_empty() || b.unknown_suspenders.is_some()) }) .collect(); Ok(json!({ "boundaries": filtered })) } else { Ok(json!({ "boundaries": boundaries_json })) } } else { Ok(json!({ "report": react::format_suspense_report(&boundaries, only_dynamic) })) } } async fn handle_vitals(cmd: &Value, state: &mut DaemonState) -> Result { // Install observers BEFORE the navigation/reload that we want to measure. // The script is idempotent — a no-op if already installed on the current page. { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let _ = mgr.evaluate(react::scripts::VITALS_INIT, None).await?; } // Register as an init script too, so navigations done via `vitals --url` // start observing from the first paint. { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let _ = mgr .add_script_to_evaluate(react::scripts::VITALS_INIT) .await; } // Navigate to the target URL (or reload the current page) to trigger a // full page load the observers can capture. let target = cmd.get("url").and_then(|v| v.as_str()).map(String::from); if let Some(url) = target { let mgr = state.browser.as_mut().ok_or("Browser not launched")?; let _ = mgr.navigate(&url, WaitUntil::Load).await?; } else { handle_reload(state).await?; } // Give layout shifts and React effects a chance to settle. tokio::time::sleep(std::time::Duration::from_millis(3000)).await; let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let url = mgr.get_url().await.unwrap_or_default(); let result = mgr.evaluate(react::scripts::VITALS_READ, None).await?; let raw = parse_json_string(result, "vitals")?; // The raw payload has { cwv, timing, ttfb }. Merge with URL and process // timing into React hydration phases + per-component durations. let cwv = raw.get("cwv").cloned().unwrap_or(json!({})); let timing = raw .get("timing") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let ttfb = raw.get("ttfb").and_then(|v| v.as_f64()); let lcp = cwv.get("lcp").cloned().unwrap_or(Value::Null); let cls_score = cwv.get("cls").and_then(|v| v.as_f64()).unwrap_or(0.0); let cls_entries = cwv.get("clsEntries").cloned().unwrap_or(json!([])); let fcp = cwv.get("fcp").and_then(|v| v.as_f64()); let inp = cwv.get("inp").and_then(|v| v.as_f64()); let round = |n: f64| (n * 100.0).round() / 100.0; let mut hydration_phases: Vec = Vec::new(); let mut hydration_start = f64::INFINITY; let mut hydration_end = 0.0f64; let mut hydrated_components: Vec = Vec::new(); // React's profiling build emits `console.timeStamp(label, start, end, // track, trackGroup, color)` entries whose `track` / `trackGroup` // fields are literal strings containing the atom glyph (e.g. // "Scheduler ⚛", "Components ⚛"). The comparisons below match those // exact strings — don't "clean up" the glyphs. for e in &timing { let label = e.get("label").and_then(|v| v.as_str()).unwrap_or(""); let track = e.get("track").and_then(|v| v.as_str()).unwrap_or(""); let track_group = e.get("trackGroup").and_then(|v| v.as_str()).unwrap_or(""); let color = e.get("color").and_then(|v| v.as_str()).unwrap_or(""); let start = e.get("startTime").and_then(|v| v.as_f64()).unwrap_or(0.0); let end = e.get("endTime").and_then(|v| v.as_f64()).unwrap_or(0.0); if end <= start { continue; } if track_group == "Scheduler ⚛" { hydration_phases.push(json!({ "label": label, "startTime": round(start), "endTime": round(end), "duration": round(end - start), })); if label == "Hydrated" { if start < hydration_start { hydration_start = start; } if end > hydration_end { hydration_end = end; } } } else if track == "Components ⚛" && color.starts_with("tertiary") { hydrated_components.push(json!({ "name": label, "startTime": round(start), "endTime": round(end), "duration": round(end - start), })); } } hydrated_components.sort_by(|a, b| { let da = a.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0); let db = b.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0); db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal) }); let hydration = if hydration_start.is_finite() && hydration_end > 0.0 { json!({ "startTime": round(hydration_start), "endTime": round(hydration_end), "duration": round(hydration_end - hydration_start), }) } else { Value::Null }; let data_value = json!({ "url": url, "ttfb": ttfb, "lcp": lcp, "cls": { "score": round(cls_score), "entries": cls_entries }, "fcp": fcp, "inp": inp, "hydration": hydration, "phases": hydration_phases, "hydratedComponents": hydrated_components, }); // Always return the structured payload. The CLI output layer renders a // compact text summary in normal mode, while `--json` exposes these exact // fields for automation. Ok(data_value) } async fn handle_pushstate(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let url = cmd .get("url") .and_then(|v| v.as_str()) .ok_or("Missing 'url' parameter")?; let script = react::scripts::PUSHSTATE.replace( "{{URL}}", &serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()), ); let result = mgr.evaluate(&script, None).await?; let after = result.as_str().map(String::from).unwrap_or_default(); Ok(json!({ "url": after })) } async fn handle_addstyle(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let content = cmd .get("content") .or_else(|| cmd.get("css")) .and_then(|v| v.as_str()); let url = cmd.get("url").and_then(|v| v.as_str()); if content.is_none() && url.is_none() { return Err("At least one of 'content' or 'url' is required".to_string()); } if let Some(href) = url { let js = format!( r#"new Promise((resolve, reject) => {{ const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = {}; link.onload = () => resolve(true); link.onerror = () => reject(new Error('Failed to load stylesheet')); document.head.appendChild(link); }})"#, serde_json::to_string(href).unwrap_or_default() ); mgr.evaluate(&js, None).await?; } else if let Some(css) = content { let js = format!( r#"(() => {{ const style = document.createElement('style'); style.textContent = {}; document.head.appendChild(style); }})()"#, serde_json::to_string(css).unwrap_or_default() ); mgr.evaluate(&js, None).await?; } Ok(json!({ "added": true })) } async fn handle_clipboard(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let action = cmd .get("subAction") .or_else(|| cmd.get("operation")) .and_then(|v| v.as_str()) .unwrap_or("read"); let session_id = mgr.active_session_id()?.to_string(); // cfg! is compile-time; assumes the browser runs on the same OS as the CLI binary. let modifier: i32 = if cfg!(target_os = "macos") { 4 } else { 2 }; match action { "write" => { let text = cmd .get("text") .or_else(|| cmd.get("value")) .and_then(|v| v.as_str()) .ok_or("Missing 'text' parameter")?; let js = format!( "navigator.clipboard.writeText({})", serde_json::to_string(text).unwrap_or_default() ); mgr.evaluate(&js, None).await?; Ok(json!({ "written": text })) } "copy" => { interaction::press_key_with_modifiers(&mgr.client, &session_id, "c", Some(modifier)) .await?; Ok(json!({ "copied": true })) } "paste" => { interaction::press_key_with_modifiers(&mgr.client, &session_id, "v", Some(modifier)) .await?; Ok(json!({ "pasted": true })) } _ => { let result = mgr.evaluate("navigator.clipboard.readText()", None).await?; Ok(json!({ "text": result })) } } } async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); let delta_x = cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0); let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0); mgr.client .send_command( "Input.dispatchMouseEvent", Some(json!({ "type": "mouseWheel", "x": x, "y": y, "deltaX": delta_x, "deltaY": delta_y, })), Some(&session_id), ) .await?; Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y })) } async fn handle_device(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let name = cmd .get("name") .or_else(|| cmd.get("device")) .and_then(|v| v.as_str()) .ok_or("Missing 'name' parameter")?; let (width, height, scale, mobile, ua) = match name.to_lowercase().as_str() { "iphone 15" | "iphone15" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"), "iphone 16" | "iphone16" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), "iphone 16 pro" | "iphone16pro" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), "iphone 17" | "iphone17" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1"), "ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), "ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), "pixel 9" | "pixel9" => (412, 923, 2.625, true, "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), "galaxy s25" | "galaxys25" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 15; SM-S931B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), // Legacy aliases "iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"), "iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"), "pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), "pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"), "galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), _ => return Err(format!("Unknown device: {}. Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro, Pixel 9, Galaxy S25", name)), }; mgr.set_viewport(width, height, scale, mobile).await?; mgr.set_user_agent(ua).await?; state.viewport = Some((width, height, scale, mobile)); // Update stream server viewport so status messages and screencast use the new dimensions if let Some(ref server) = state.stream_server { server.set_viewport(width as u32, height as u32).await; } Ok(json!({ "device": name, "width": width, "height": height, "deviceScaleFactor": scale, "mobile": mobile, })) } // --------------------------------------------------------------------------- // Stream handlers // --------------------------------------------------------------------------- fn stream_file_path(session_id: &str) -> PathBuf { get_socket_dir().join(format!("{}.stream", session_id)) } fn write_stream_file(session_id: &str, port: u16) -> Result<(), String> { let path = stream_file_path(session_id); fs::write(&path, port.to_string()).map_err(|e| { format!( "Failed to write stream metadata '{}': {}", path.display(), e ) }) } fn remove_stream_file(session_id: &str) -> Result<(), String> { let path = stream_file_path(session_id); match fs::remove_file(&path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(format!( "Failed to remove stream metadata '{}': {}", path.display(), err )), } } fn engine_file_path(session_id: &str) -> PathBuf { get_socket_dir().join(format!("{}.engine", session_id)) } fn write_engine_file(session_id: &str, engine: &str) { let _ = fs::write(engine_file_path(session_id), engine); } fn remove_engine_file(session_id: &str) { let _ = fs::remove_file(engine_file_path(session_id)); } fn provider_file_path(session_id: &str) -> PathBuf { get_socket_dir().join(format!("{}.provider", session_id)) } fn write_provider_file(session_id: &str, provider: &str) { let _ = fs::write(provider_file_path(session_id), provider); } fn remove_provider_file(session_id: &str) { let _ = fs::remove_file(provider_file_path(session_id)); } fn extensions_file_path(session_id: &str) -> PathBuf { get_socket_dir().join(format!("{}.extensions", session_id)) } fn write_extensions_file(session_id: &str) { if let Ok(val) = env::var("AGENT_BROWSER_EXTENSIONS") { let trimmed = val.trim(); if !trimmed.is_empty() { let _ = fs::write(extensions_file_path(session_id), trimmed); return; } } let _ = fs::remove_file(extensions_file_path(session_id)); } fn write_extensions_file_from_paths(session_id: &str, extensions: Option<&[String]>) { let Some(paths) = extensions else { write_extensions_file(session_id); return; }; let joined = paths .iter() .map(|path| path.trim()) .filter(|path| !path.is_empty()) .collect::>() .join(","); if joined.is_empty() { let _ = fs::remove_file(extensions_file_path(session_id)); } else { let _ = fs::write(extensions_file_path(session_id), joined); } } fn remove_extensions_file(session_id: &str) { let _ = fs::remove_file(extensions_file_path(session_id)); } async fn current_stream_status(state: &DaemonState) -> Value { debug_assert_eq!( state.stream_server.is_some(), state.stream_client.is_some(), "stream server and stream client slot should be set together" ); let connected = match state.browser.as_ref() { Some(mgr) => mgr.is_connection_alive().await, None => false, }; let runtime_screencasting = match state.stream_server.as_ref() { Some(server) => server.is_screencasting().await, None => false, }; json!({ "enabled": state.stream_server.is_some(), "port": state .stream_server .as_ref() .map(|server| Value::from(server.port())) .unwrap_or(Value::Null), "connected": connected, "screencasting": connected && (state.screencasting || runtime_screencasting), }) } async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result { if state.stream_server.is_some() { return Err("Streaming is already enabled for this session".to_string()); } let requested_port = match cmd.get("port").and_then(|value| value.as_u64()) { Some(raw) => u16::try_from(raw) .map_err(|_| format!("Invalid stream port '{}': expected 0-65535", raw))?, None => 0, }; let (server, client_slot) = StreamServer::start_without_client(requested_port, state.session_id.clone(), false).await?; let port = server.port(); if let Err(err) = write_stream_file(&state.session_id, port) { server.shutdown().await; return Err(err); } state.stream_client = Some(client_slot); state.stream_server = Some(Arc::new(server)); state.request_tracking = true; if state.screencasting { if let Some(ref server) = state.stream_server { server.set_screencasting(true).await; } } state.update_stream_client().await; Ok(current_stream_status(state).await) } async fn handle_stream_disable(state: &mut DaemonState) -> Result { let Some(server) = state.stream_server.clone() else { return Err("Streaming is not enabled for this session".to_string()); }; server.shutdown().await; state.stream_server = None; state.stream_client = None; remove_stream_file(&state.session_id)?; remove_engine_file(&state.session_id); remove_provider_file(&state.session_id); Ok(json!({ "disabled": true })) } async fn handle_stream_status(state: &DaemonState) -> Result { Ok(current_stream_status(state).await) } // --------------------------------------------------------------------------- // Screencast handlers // --------------------------------------------------------------------------- async fn handle_screencast_start(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(); if state.screencasting { return Err("Screencast already active".to_string()); } // Use stored viewport as default for screencast dimensions let (default_w, default_h) = if let Some(ref server) = state.stream_server { server.viewport().await } else { (1280, 720) }; let format = cmd.get("format").and_then(|v| v.as_str()).unwrap_or("jpeg"); let quality = cmd.get("quality").and_then(|v| v.as_i64()).unwrap_or(80) as i32; let max_width = cmd .get("maxWidth") .and_then(|v| v.as_i64()) .unwrap_or(default_w as i64) as i32; let max_height = cmd .get("maxHeight") .and_then(|v| v.as_i64()) .unwrap_or(default_h as i64) as i32; stream::start_screencast( &mgr.client, &session_id, format, quality, max_width, max_height, ) .await?; state.screencasting = true; if let Some(ref server) = state.stream_server { server.set_screencasting(true).await; server .broadcast_status( true, true, max_width as u32, max_height as u32, &state.engine, ) .await; } Ok(json!({ "started": true })) } async fn handle_screencast_stop(state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?; if !state.screencasting { return Err("No screencast active".to_string()); } stream::stop_screencast(&mgr.client, session_id).await?; state.screencasting = false; if let Some(ref server) = state.stream_server { server.set_screencasting(false).await; let (vw, vh) = server.viewport().await; server .broadcast_status(true, false, vw, vh, &state.engine) .await; } Ok(json!({ "stopped": true })) } // --------------------------------------------------------------------------- // Wait variant handlers // --------------------------------------------------------------------------- async fn handle_waitforurl(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let url_pattern = cmd .get("url") .and_then(|v| v.as_str()) .ok_or("Missing 'url' parameter")?; let timeout_ms = state.timeout_ms(cmd); wait_for_url(mgr, url_pattern, timeout_ms).await?; let url = mgr.get_url().await.unwrap_or_default(); Ok(json!({ "url": url })) } async fn handle_waitforloadstate(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let load_state = cmd.get("state").and_then(|v| v.as_str()).unwrap_or("load"); let timeout_ms = state.timeout_ms(cmd); let wait_until = WaitUntil::from_str(load_state); let _ = tokio::time::timeout( tokio::time::Duration::from_millis(timeout_ms), mgr.wait_for_lifecycle_external(wait_until, &session_id), ) .await .map_err(|_| format!("Timeout waiting for load state: {}", load_state))?; Ok(json!({ "state": load_state })) } async fn handle_waitforfunction(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let expression = cmd .get("expression") .and_then(|v| v.as_str()) .ok_or("Missing 'expression' parameter")?; let timeout_ms = state.timeout_ms(cmd); wait_for_function(&mgr.client, &session_id, expression, timeout_ms).await?; let result: super::cdp::types::EvaluateResult = mgr .client .send_command_typed( "Runtime.evaluate", &super::cdp::types::EvaluateParams { expression: format!("({})", expression), return_by_value: Some(true), await_promise: Some(true), }, Some(&session_id), ) .await?; Ok(json!({ "result": result.result.value.unwrap_or(Value::Null) })) } // --------------------------------------------------------------------------- // Frame handlers // --------------------------------------------------------------------------- async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_mut().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()); let name = cmd.get("name").and_then(|v| v.as_str()); let url = cmd.get("url").and_then(|v| v.as_str()); if selector.is_none() && name.is_none() && url.is_none() { return Err("At least one of 'selector', 'name', or 'url' is required".to_string()); } let tree_result = mgr .client .send_command_no_params("Page.getFrameTree", Some(&session_id)) .await?; fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option { let frame = tree.get("frame")?; let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or(""); let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or(""); let frame_id = frame.get("id").and_then(|v| v.as_str())?; if let Some(n) = name { if frame_name == n { return Some(frame_id.to_string()); } } if let Some(u) = url { if frame_url.contains(u) { return Some(frame_id.to_string()); } } if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) { for child in children { if let Some(id) = find_frame(child, name, url) { return Some(id); } } } None } let frame_tree = &tree_result["frameTree"]; // If selector is a ref (@e1), resolve the iframe element from the ref map if let Some(sel) = selector { if let Some(ref_id) = super::element::parse_ref(sel) { let entry = state .ref_map .get(&ref_id) .ok_or_else(|| format!("Unknown ref: {}", ref_id))?; let backend_node_id = entry .backend_node_id .ok_or_else(|| format!("Ref {} has no backend node id", ref_id))?; // Use DOM.describeNode to resolve the child frame ID directly. // This works reliably for all iframes, including those without // name, id, or src attributes. let describe: Value = mgr .client .send_command( "DOM.describeNode", Some(json!({ "backendNodeId": backend_node_id, "depth": 1 })), Some(&session_id), ) .await?; // Verify this is an iframe/frame element let node_name = describe .get("node") .and_then(|n| n.get("nodeName")) .and_then(|v| v.as_str()) .unwrap_or(""); if node_name != "IFRAME" && node_name != "FRAME" { return Err("Ref does not point to an iframe element".to_string()); } // Try contentDocument.frameId first (standard for iframes) let frame_id = describe .get("node") .and_then(|n| n.get("contentDocument")) .and_then(|cd| cd.get("frameId")) .and_then(|v| v.as_str()) // Fallback: the node itself may carry a frameId .or_else(|| { describe .get("node") .and_then(|n| n.get("frameId")) .and_then(|v| v.as_str()) }) .ok_or("Could not resolve frame ID for iframe element")?; let label = describe .get("node") .and_then(|n| n.get("attributes")) .and_then(|a| a.as_array()) .and_then(|attrs| { attrs .iter() .enumerate() .find(|(_, v)| v.as_str() == Some("name")) .and_then(|(i, _)| attrs.get(i + 1)) .and_then(|v| v.as_str()) }) .unwrap_or(&ref_id); state.active_frame_id = Some(frame_id.to_string()); return Ok(json!({ "frame": label })); } // CSS selector path let js = format!( r#"(() => {{ const el = document.querySelector({}); if (!el) return null; if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {{ return el.name || el.id || el.src || null; }} return null; }})()"#, serde_json::to_string(sel).unwrap_or_default() ); let result = mgr.evaluate(&js, None).await?; let frame_name = result.as_str().ok_or("Could not find frame for selector")?; if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) { state.active_frame_id = Some(frame_id); return Ok(json!({ "frame": frame_name })); } } if let Some(frame_id) = find_frame(frame_tree, name, url) { let label = name.or(url).unwrap_or("frame"); state.active_frame_id = Some(frame_id); return Ok(json!({ "frame": label })); } Err("Frame not found".to_string()) } async fn handle_mainframe(state: &mut DaemonState) -> Result { state.active_frame_id = None; Ok(json!({ "frame": "main" })) } // --------------------------------------------------------------------------- // Semantic locator handlers // --------------------------------------------------------------------------- async fn execute_subaction( cmd: &Value, state: &mut DaemonState, selector: &str, ) -> Result { let subaction = cmd .get("subaction") .and_then(|v| v.as_str()) .unwrap_or("click"); let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); match subaction { "click" => { let result = interaction::click( &mgr.client, &session_id, &state.ref_map, selector, "left", 1, &state.iframe_sessions, ) .await?; if result.dialog_opened { state.pending_pointer_release = result.pending_release; return Ok(json!({ "clicked": selector, "dialogOpened": true })); } Ok(json!({ "clicked": selector })) } "fill" => { let value = cmd .get("value") .and_then(|v| v.as_str()) .ok_or("Missing 'value' for fill subaction")?; interaction::fill( &mgr.client, &session_id, &state.ref_map, selector, value, &state.iframe_sessions, ) .await?; Ok(json!({ "filled": selector })) } "check" => { interaction::check( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "checked": selector })) } "hover" => { interaction::hover( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "hovered": selector })) } "text" => { let text = super::element::get_element_text( &mgr.client, &session_id, &state.ref_map, selector, &state.iframe_sessions, ) .await?; Ok(json!({ "text": text })) } _ => Err(format!("Unknown subaction: {}", subaction)), } } fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String { match name { Some(n) => { let exact_str = if exact { ", exact: true" } else { "" }; format!("getByRole('{}', {{ name: '{}'{} }})", role, n, exact_str) } None => format!("getByRole('{}')", role), } } async fn handle_getbyrole(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 role = cmd .get("role") .and_then(|v| v.as_str()) .ok_or("Missing 'role' parameter")?; let name = cmd.get("name").and_then(|v| v.as_str()); let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false); let name_match = name .map(|n| { if exact { format!( "el.getAttribute('aria-label') === {} || el.textContent.trim() === {}", serde_json::to_string(n).unwrap_or_default(), serde_json::to_string(n).unwrap_or_default() ) } else { format!( "(el.getAttribute('aria-label') || '').includes({n}) || el.textContent.includes({n})", n = serde_json::to_string(n).unwrap_or_default() ) } }) .unwrap_or_else(|| "true".to_string()); let js = format!( r#"(() => {{ const els = document.querySelectorAll('[role="{role}"], {role}'); for (const el of els) {{ if ({name_match}) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }} }} return false; }})()"#, role = role, name_match = name_match, ); let result: super::cdp::types::EvaluateResult = mgr .client .send_command_typed( "Runtime.evaluate", &super::cdp::types::EvaluateParams { expression: js, return_by_value: Some(true), await_promise: Some(false), }, Some(&session_id), ) .await?; if !result .result .value .as_ref() .and_then(|v| v.as_bool()) .unwrap_or(false) { let desc = build_role_selector(role, name, exact); return Err(format!("No element found: {}", desc)); } let selector = "[data-agent-browser-located='true']"; let result = execute_subaction(cmd, state, selector).await; // Clean up the marker attribute if let Some(ref browser) = state.browser { if browser.active_session_id().is_ok() { let _ = browser .evaluate( "document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')", None, ) .await; } } result } async fn handle_semantic_locator( cmd: &Value, state: &mut DaemonState, strategy: &str, param_name: &str, ) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); let value = cmd .get(param_name) .and_then(|v| v.as_str()) .ok_or(format!("Missing '{}' parameter", param_name))?; let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false); let match_fn = if exact { format!( "el.textContent.trim() === {}", serde_json::to_string(value).unwrap_or_default() ) } else { format!( "el.textContent.includes({})", serde_json::to_string(value).unwrap_or_default() ) }; let query = match strategy { // Like Playwright's getByLabel: match