diff --git a/README.md b/README.md index faaf62b..0a46838 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ agent-browser get value # Get input value agent-browser get attr # Get attribute agent-browser get title # Get page title agent-browser get url # Get current URL +agent-browser get cdp-url # Get CDP WebSocket URL (for DevTools, debugging) agent-browser get count # Count matching elements agent-browser get box # Get bounding box agent-browser get styles # Get computed styles @@ -283,6 +284,7 @@ agent-browser console --clear # Clear console agent-browser errors # View page errors (uncaught JavaScript exceptions) agent-browser errors --clear # Clear errors agent-browser highlight # Highlight element +agent-browser inspect # Open Chrome DevTools for the active page agent-browser state save # Save auth state agent-browser state load # Load auth state agent-browser state list # List saved state files diff --git a/cli/src/commands.rs b/cli/src/commands.rs index bda3eac..74cc698 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -568,6 +568,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Ok(json!({ "id": id, "action": "close" })), + // === Inspect === + "inspect" => Ok(json!({ "id": id, "action": "inspect" })), + // === Authentication Vault === "auth" => { let sub = rest.first().map(|s| s.as_ref()); @@ -1559,7 +1562,7 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result Result { const VALID: &[&str] = &[ - "text", "html", "value", "attr", "url", "title", "count", "box", "styles", + "text", "html", "value", "attr", "url", "title", "count", "box", "styles", "cdp-url", ]; match rest.first().copied() { @@ -1596,6 +1599,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result { Ok(json!({ "id": id, "action": "getattribute", "selector": sel, "attribute": attr })) } Some("url") => Ok(json!({ "id": id, "action": "url" })), + Some("cdp-url") => Ok(json!({ "id": id, "action": "cdp_url" })), Some("title") => Ok(json!({ "id": id, "action": "title" })), Some("count") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { @@ -1624,7 +1628,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result { }), None => Err(ParseError::MissingArguments { context: "get".to_string(), - usage: "get [args...]", + usage: "get [args...]", }), } } @@ -3807,4 +3811,18 @@ mod tests { ParseError::MissingArguments { .. } )); } + + // === Inspect / CDP URL === + + #[test] + fn test_inspect() { + let cmd = parse_command(&args("inspect"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "inspect"); + } + + #[test] + fn test_get_cdp_url() { + let cmd = parse_command(&args("get cdp-url"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "cdp_url"); + } } diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index f597a78..a003e0c 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -12,6 +12,7 @@ use super::cdp::types::{ 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}; @@ -96,6 +97,7 @@ pub struct DaemonState { pub har_recording: bool, pub har_entries: Vec, pub confirm_actions: Option, + pub inspect_server: Option, pub routes: Vec, pub tracked_requests: Vec, pub request_tracking: bool, @@ -127,6 +129,7 @@ impl DaemonState { har_recording: false, har_entries: Vec::new(), confirm_actions: ConfirmActions::from_env(), + inspect_server: None, routes: Vec::new(), tracked_requests: Vec::new(), request_tracking: false, @@ -567,6 +570,8 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "launch" => handle_launch(cmd, state).await, "navigate" => handle_navigate(cmd, state).await, "url" => handle_url(state).await, + "cdp_url" => handle_cdp_url(state), + "inspect" => handle_inspect(state).await, "title" => handle_title(state).await, "content" => handle_content(state).await, "evaluate" => handle_evaluate(cmd, state).await, @@ -1170,6 +1175,50 @@ async fn handle_url(state: &DaemonState) -> Result { Ok(json!({ "url": url })) } +fn handle_cdp_url(state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + Ok(json!({ "cdpUrl": mgr.get_cdp_url() })) +} + +async fn handle_inspect(state: &mut DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + + // Shut down any existing inspect server so we always target the current page + if let Some(server) = state.inspect_server.take() { + server.shutdown(); + } + + let target_id = mgr.active_target_id()?.to_string(); + let chrome_hp = mgr.chrome_host_port().to_string(); + let proxy_handle = mgr.client.inspect_handle(); + + let server = InspectServer::start(proxy_handle, target_id, chrome_hp).await?; + let url = format!("http://127.0.0.1:{}", server.port()); + open_url_in_browser(&url); + + state.inspect_server = Some(server); + Ok(json!({ "opened": true, "url": url })) +} + +fn open_url_in_browser(url: &str) { + #[cfg(target_os = "macos")] + let result = std::process::Command::new("open").arg(url).spawn(); + #[cfg(target_os = "linux")] + let result = std::process::Command::new("xdg-open").arg(url).spawn(); + #[cfg(target_os = "windows")] + let result = std::process::Command::new("cmd") + .args(["/c", "start", "", url]) + .spawn(); + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "unsupported platform", + )); + if let Err(e) = result { + eprintln!("[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() { @@ -1254,6 +1303,10 @@ async fn handle_close(state: &mut DaemonState) -> Result { state.safari_driver = None; state.backend_type = BackendType::Cdp; + if let Some(server) = state.inspect_server.take() { + server.shutdown(); + } + state.ref_map.clear(); Ok(json!({ "closed": true })) } diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index b173e58..84172b7 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -161,6 +161,7 @@ impl BrowserProcess { pub struct BrowserManager { pub client: CdpClient, browser_process: Option, + ws_url: String, pages: Vec, active_page_index: usize, default_timeout_ms: u64, @@ -223,6 +224,7 @@ impl BrowserManager { let mut manager = Self { client, browser_process: Some(process), + ws_url, pages: Vec::new(), active_page_index: 0, default_timeout_ms: 25_000, @@ -285,6 +287,7 @@ impl BrowserManager { let mut manager = Self { client, browser_process: None, + ws_url, pages: Vec::new(), active_page_index: 0, default_timeout_ms: 10_000, @@ -636,6 +639,27 @@ impl BrowserManager { } } + pub fn get_cdp_url(&self) -> &str { + &self.ws_url + } + + /// Returns the Chrome debug server address as "host:port". + pub fn chrome_host_port(&self) -> &str { + let stripped = self + .ws_url + .strip_prefix("ws://") + .or_else(|| self.ws_url.strip_prefix("wss://")) + .unwrap_or(&self.ws_url); + stripped.split('/').next().unwrap_or(stripped) + } + + pub fn active_target_id(&self) -> Result<&str, String> { + self.pages + .get(self.active_page_index) + .map(|p| p.target_id.as_str()) + .ok_or_else(|| "No active page".to_string()) + } + /// Returns true if this manager was connected via CDP (as opposed to local launch). pub fn is_cdp_connection(&self) -> bool { self.browser_process.is_none() diff --git a/cli/src/native/cdp/client.rs b/cli/src/native/cdp/client.rs index 3e6cf61..e8056d9 100644 --- a/cli/src/native/cdp/client.rs +++ b/cli/src/native/cdp/client.rs @@ -12,6 +12,14 @@ use super::types::{CdpCommand, CdpEvent, CdpMessage}; type PendingMap = Arc>>>; +/// Raw incoming CDP message (text) broadcast to all subscribers. +/// Used by the inspect proxy to forward responses and events to DevTools. +#[derive(Debug, Clone)] +pub struct RawCdpMessage { + pub text: String, + pub session_id: Option, +} + pub struct CdpClient { ws_tx: Arc< Mutex< @@ -26,6 +34,7 @@ pub struct CdpClient { next_id: AtomicU64, pending: PendingMap, event_tx: broadcast::Sender, + raw_tx: broadcast::Sender, _reader_handle: tokio::task::JoinHandle<()>, } @@ -40,9 +49,11 @@ impl CdpClient { let pending: PendingMap = Arc::new(Mutex::new(HashMap::new())); let (event_tx, _) = broadcast::channel(256); + let (raw_tx, _) = broadcast::channel(512); let pending_clone = pending.clone(); let event_tx_clone = event_tx.clone(); + let raw_tx_clone = raw_tx.clone(); let reader_handle = tokio::spawn(async move { while let Some(msg) = ws_rx.next().await { @@ -53,8 +64,22 @@ impl CdpClient { Err(_) => break, }; + // Broadcast raw message for inspect proxy subscribers before typed parse, + // so messages with negative IDs (used by the inspect proxy) are still delivered. + if raw_tx_clone.receiver_count() > 0 { + let session_id = serde_json::from_str::(&msg) + .ok() + .and_then(|v| v.get("sessionId")?.as_str().map(String::from)); + let _ = raw_tx_clone.send(RawCdpMessage { + text: msg.clone(), + session_id, + }); + } + let parsed: CdpMessage = match serde_json::from_str(&msg) { Ok(m) => m, + // Expected for inspect proxy messages with negative IDs + // (CdpMessage.id is u64); handled via raw broadcast above. Err(_) => continue, }; @@ -81,6 +106,7 @@ impl CdpClient { next_id: AtomicU64::new(1), pending, event_tx, + raw_tx, _reader_handle: reader_handle, }) } @@ -138,6 +164,21 @@ impl CdpClient { self.event_tx.subscribe() } + /// Subscribe to all raw incoming CDP messages (responses + events). + /// Used by the inspect proxy to forward traffic to the DevTools frontend. + pub fn subscribe_raw(&self) -> broadcast::Receiver { + self.raw_tx.subscribe() + } + + /// Create a lightweight handle for the inspect WebSocket proxy. + /// Contains only what's needed to forward messages bidirectionally. + pub fn inspect_handle(&self) -> InspectProxyHandle { + InspectProxyHandle { + ws_tx: self.ws_tx.clone(), + raw_tx: self.raw_tx.clone(), + } + } + pub async fn send_command_typed( &self, method: &str, @@ -160,4 +201,46 @@ impl CdpClient { ) -> Result { self.send_command(method, None, session_id).await } + + /// Send raw JSON through the WebSocket without tracking a response. + /// Used by the inspect proxy to forward DevTools frontend messages. + pub async fn send_raw(&self, json: String) -> Result<(), String> { + let mut ws_tx = self.ws_tx.lock().await; + ws_tx + .send(Message::Text(json)) + .await + .map_err(|e| format!("Failed to send raw CDP message: {}", e)) + } +} + +type WsTx = Arc< + Mutex< + futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + >, +>; + +/// Lightweight handle for the inspect WebSocket proxy, holding only +/// the cloneable parts of CdpClient needed for bidirectional message forwarding. +pub struct InspectProxyHandle { + ws_tx: WsTx, + raw_tx: broadcast::Sender, +} + +impl InspectProxyHandle { + pub async fn send_raw(&self, json: String) -> Result<(), String> { + let mut ws_tx = self.ws_tx.lock().await; + ws_tx + .send(Message::Text(json)) + .await + .map_err(|e| format!("Failed to send raw CDP message: {}", e)) + } + + pub fn subscribe_raw(&self) -> broadcast::Receiver { + self.raw_tx.subscribe() + } } diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index be6998c..456729c 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -1544,3 +1544,86 @@ async fn e2e_profile_cookie_persistence() { let _ = std::fs::remove_dir_all(&profile_dir); } + +// --------------------------------------------------------------------------- +// Inspect / CDP URL +// --------------------------------------------------------------------------- + +#[tokio::test] +#[ignore] +async fn e2e_get_cdp_url() { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "1", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "2", "action": "cdp_url" }), &mut state).await; + assert_success(&resp); + let cdp_url = get_data(&resp)["cdpUrl"] + .as_str() + .expect("cdpUrl should be a string"); + assert!( + cdp_url.starts_with("ws://"), + "CDP URL should start with ws://, got: {}", + cdp_url + ); + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); +} + +#[tokio::test] +#[ignore] +async fn e2e_inspect() { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "1", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "3", "action": "inspect" }), &mut state).await; + assert_success(&resp); + let data = get_data(&resp); + assert_eq!(data["opened"], true); + let url = data["url"] + .as_str() + .expect("inspect url should be a string"); + assert!( + url.starts_with("http://127.0.0.1:"), + "Inspect URL should be http://127.0.0.1:, got: {}", + url + ); + + // Verify the HTTP redirect serves a 302 to the DevTools frontend + let http_resp = reqwest::get(url).await; + match http_resp { + Ok(r) => { + let final_url = r.url().to_string(); + assert!( + final_url.contains("devtools/devtools_app.html"), + "Redirect should point to DevTools frontend, got: {}", + final_url + ); + } + Err(e) => { + panic!("HTTP GET to inspect URL failed: {}", e); + } + } + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); +} diff --git a/cli/src/native/inspect_server.rs b/cli/src/native/inspect_server.rs new file mode 100644 index 0000000..518c2ad --- /dev/null +++ b/cli/src/native/inspect_server.rs @@ -0,0 +1,360 @@ +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message; + +use super::cdp::client::InspectProxyHandle; + +/// Counter for unique attach IDs so concurrent connections don't collide. +static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000); + +/// Lightweight HTTP + WebSocket server for `agent-browser inspect`. +/// +/// Serves two purposes: +/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server +/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy +/// CDP messages through the daemon's existing browser-level connection, injecting/stripping +/// `sessionId` so the DevTools frontend sees a page-level view +pub struct InspectServer { + port: u16, + _handle: tokio::task::JoinHandle<()>, +} + +impl InspectServer { + /// Start the inspect proxy server. + /// + /// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages + /// - `target_id`: the CDP target ID of the page to inspect + /// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222") + pub async fn start( + proxy_handle: InspectProxyHandle, + target_id: String, + chrome_host_port: String, + ) -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind inspect server: {}", e))?; + let port = listener + .local_addr() + .map_err(|e| format!("Failed to get local addr: {}", e))? + .port(); + + let proxy = Arc::new(proxy_handle); + + let handle = tokio::spawn(accept_loop( + listener, + proxy, + target_id, + chrome_host_port, + port, + )); + + Ok(Self { + port, + _handle: handle, + }) + } + + pub fn port(&self) -> u16 { + self.port + } + + pub fn shutdown(self) { + self._handle.abort(); + } +} + +async fn accept_loop( + listener: TcpListener, + proxy: Arc, + target_id: String, + chrome_host_port: String, + proxy_port: u16, +) { + loop { + let (stream, _) = match listener.accept().await { + Ok(s) => s, + Err(_) => continue, + }; + + let proxy = proxy.clone(); + let tid = target_id.clone(); + let chp = chrome_host_port.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await { + eprintln!("[inspect] connection error: {}", e); + } + }); + } +} + +async fn handle_connection( + stream: tokio::net::TcpStream, + proxy: Arc, + target_id: String, + chrome_host_port: String, + proxy_port: u16, +) -> Result<(), String> { + // Peek at the request line to determine routing WITHOUT consuming bytes. + // This is critical: tokio_tungstenite::accept_async needs to read the full + // HTTP upgrade request itself, so we must not consume anything for WS paths. + let mut peek_buf = [0u8; 32]; + let n = stream + .peek(&mut peek_buf) + .await + .map_err(|e| e.to_string())?; + let peek = String::from_utf8_lossy(&peek_buf[..n]); + + if peek.starts_with("GET /ws") { + return handle_ws_proxy(stream, proxy, target_id).await; + } + + if peek.starts_with("GET / ") { + let buf_reader = BufReader::new(stream); + return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await; + } + + // Unknown request -- consume and respond 404 + let mut stream = stream; + let mut discard = [0u8; 4096]; + let _ = stream.read(&mut discard).await; + let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + stream + .write_all(resp.as_bytes()) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +const MAX_HEADER_BYTES: usize = 8192; + +async fn handle_http_redirect( + buf_reader: BufReader, + chrome_host_port: String, + proxy_port: u16, +) -> Result<(), String> { + let mut br = buf_reader; + let mut total_bytes = 0usize; + loop { + let mut line = String::new(); + let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?; + total_bytes += n; + if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES { + break; + } + } + + let location = format!( + "http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws", + chrome_host_port, proxy_port + ); + let body = format!( + "Redirecting to {url}", + url = location + ); + let resp = format!( + "HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + location, + body.len(), + body + ); + let mut stream = br.into_inner(); + stream + .write_all(resp.as_bytes()) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +async fn handle_ws_proxy( + stream: tokio::net::TcpStream, + proxy: Arc, + target_id: String, +) -> Result<(), String> { + let ws_stream = tokio_tungstenite::accept_async(stream) + .await + .map_err(|e| format!("WebSocket handshake failed: {}", e))?; + + // Create a dedicated CDP session for this DevTools connection. + // Each connection gets its own session so domain enablements (DOM.enable, etc.) + // always trigger fresh initial state dumps from Chrome. + let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst); + let attach_cmd = format!( + r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#, + attach_id, target_id + ); + + // Subscribe BEFORE sending so we don't miss the response (tokio broadcast + // receivers only deliver messages to receivers that already exist). + let mut raw_rx = proxy.subscribe_raw(); + + proxy + .send_raw(attach_cmd) + .await + .map_err(|e| format!("Failed to send attachToTarget: {}", e))?; + + // Wait for the attachToTarget response to extract the session ID + let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Ok(raw_msg) = raw_rx.recv().await { + if let Ok(val) = serde_json::from_str::(&raw_msg.text) { + if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) { + if let Some(sid) = val + .get("result") + .and_then(|r| r.get("sessionId")) + .and_then(|s| s.as_str()) + { + return Ok(sid.to_string()); + } + return Err("attachToTarget failed".to_string()); + } + } + } + Err("raw message channel closed".to_string()) + }) + .await + .map_err(|_| "Timed out waiting for attachToTarget response".to_string())? + .map_err(|e| format!("Failed to create DevTools session: {}", e))?; + + let (ws_tx, mut ws_rx) = ws_stream.split(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + + let mut raw_rx = proxy.subscribe_raw(); + let ws_tx_clone = ws_tx.clone(); + let session_id_clone = session_id.clone(); + + // Chrome -> DevTools: forward messages matching our session, strip sessionId + let mut chrome_to_devtools = tokio::spawn(async move { + loop { + let raw_msg = match raw_rx.recv().await { + Ok(msg) => msg, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + eprintln!( + "[inspect] warning: dropped {} CDP messages (channel lag)", + n + ); + continue; + } + Err(_) => break, + }; + + if raw_msg.session_id.as_deref() != Some(&session_id_clone) { + continue; + } + + let stripped = strip_session_id(&raw_msg.text); + + let mut tx = ws_tx_clone.lock().await; + if tx.send(Message::Text(stripped)).await.is_err() { + break; + } + } + }); + + // DevTools -> Chrome: inject sessionId and forward + let proxy_for_send = proxy.clone(); + let session_id_for_send = session_id.clone(); + let mut devtools_to_chrome = tokio::spawn(async move { + while let Some(Ok(msg)) = ws_rx.next().await { + let text = match msg { + Message::Text(t) => t, + Message::Close(_) => break, + _ => continue, + }; + + let injected = inject_session_id(&text, &session_id_for_send); + if proxy_for_send.send_raw(injected).await.is_err() { + break; + } + } + }); + + tokio::select! { + _ = &mut chrome_to_devtools => { + devtools_to_chrome.abort(); + }, + _ = &mut devtools_to_chrome => { + chrome_to_devtools.abort(); + }, + } + + // Clean up the CDP session so Chrome doesn't leak attached targets + let detach_cmd = format!( + r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#, + ATTACH_ID.fetch_sub(1, Ordering::SeqCst), + session_id + ); + let _ = proxy.send_raw(detach_cmd).await; + + Ok(()) +} + +fn inject_session_id(json: &str, session_id: &str) -> String { + if let Ok(mut val) = serde_json::from_str::(json) { + if let Some(obj) = val.as_object_mut() { + obj.insert( + "sessionId".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + serde_json::to_string(&val).unwrap_or_else(|_| json.to_string()) + } else { + json.to_string() + } +} + +fn strip_session_id(json: &str) -> String { + if let Ok(mut val) = serde_json::from_str::(json) { + if let Some(obj) = val.as_object_mut() { + obj.remove("sessionId"); + } + serde_json::to_string(&val).unwrap_or_else(|_| json.to_string()) + } else { + json.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inject_session_id() { + let input = r#"{"id":1,"method":"DOM.getDocument"}"#; + let result = inject_session_id(input, "abc123"); + let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON"); + assert_eq!(parsed["sessionId"], "abc123"); + assert_eq!(parsed["method"], "DOM.getDocument"); + assert_eq!(parsed["id"], 1); + } + + #[test] + fn test_inject_session_id_empty_object() { + let result = inject_session_id("{}", "abc"); + let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON"); + assert_eq!(parsed["sessionId"], "abc"); + } + + #[test] + fn test_strip_session_id() { + let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#; + let result = strip_session_id(input); + let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON"); + assert!(parsed.get("sessionId").is_none()); + assert_eq!(parsed["id"], 1); + } + + #[test] + fn test_inject_then_strip_roundtrip() { + let input = r#"{"id":42,"method":"Runtime.evaluate"}"#; + let injected = inject_session_id(input, "sess1"); + let stripped = strip_session_id(&injected); + let original: serde_json::Value = serde_json::from_str(input).unwrap(); + let result: serde_json::Value = serde_json::from_str(&stripped).unwrap(); + assert_eq!(original, result); + } +} diff --git a/cli/src/native/mod.rs b/cli/src/native/mod.rs index e24a98c..f86c816 100644 --- a/cli/src/native/mod.rs +++ b/cli/src/native/mod.rs @@ -15,6 +15,8 @@ pub mod diff; #[allow(dead_code)] pub mod element; #[allow(dead_code)] +pub mod inspect_server; +#[allow(dead_code)] pub mod interaction; #[allow(dead_code)] pub mod network; diff --git a/cli/src/output.rs b/cli/src/output.rs index 0cd2f1e..a03b73f 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -100,6 +100,23 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } if let Some(data) = &resp.data { + // Inspect response (check before generic URL handler since it also has a "url" field) + if action == Some("inspect") { + let opened = data + .get("opened") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if opened { + if let Some(url) = data.get("url").and_then(|v| v.as_str()) { + println!("{} Opened DevTools: {}", color::success_indicator(), url); + } else { + println!("{} Opened DevTools", color::success_indicator()); + } + } else if let Some(err) = data.get("error").and_then(|v| v.as_str()) { + eprintln!("Could not open DevTools: {}", err); + } + return; + } // Navigation response if let Some(url) = data.get("url").and_then(|v| v.as_str()) { if let Some(title) = data.get("title").and_then(|v| v.as_str()) { @@ -110,6 +127,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou println!("{}", url); return; } + if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) { + println!("{}", cdp_url); + return; + } // Diff responses -- route by action to avoid fragile shape probing if let Some(obj) = data.as_object() { match action { @@ -1447,6 +1468,25 @@ Examples: "## } + // === Inspect === + "inspect" => { + r##" +agent-browser inspect - Open Chrome DevTools for the active page + +Starts a local WebSocket proxy and opens Chrome's DevTools frontend in your +default browser. The proxy routes DevTools traffic through the daemon's +existing CDP connection, so both DevTools and agent-browser commands work +simultaneously. + +Usage: agent-browser inspect + +Examples: + agent-browser open example.com + agent-browser inspect # opens DevTools in your browser + agent-browser click "Submit" # commands still work while DevTools is open +"## + } + // === Get === "get" => { r##" @@ -1466,6 +1506,7 @@ Subcommands: count Count matching elements box Get bounding box (x, y, width, height) styles Get computed styles of elements + cdp-url Get Chrome DevTools Protocol WebSocket URL Global Options: --json Output as JSON @@ -2351,7 +2392,7 @@ Navigation: reload Reload page Get Info: agent-browser get [selector] - text, html, value, attr , title, url, count, box, styles + text, html, value, attr , title, url, count, box, styles, cdp-url Check State: agent-browser is visible, enabled, checked @@ -2392,6 +2433,7 @@ Debug: console [--clear] View console logs errors [--clear] View page errors highlight Highlight element + inspect Open Chrome DevTools for the active page Auth Vault: auth save [opts] Save auth profile (--url, --username, --password/--password-stdin) diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index acec1fd..1640d6b 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -44,6 +44,7 @@ agent-browser get value # Get input value agent-browser get attr # Get attribute agent-browser get title # Get page title agent-browser get url # Get current URL +agent-browser get cdp-url # Get CDP WebSocket URL agent-browser get count # Count matching elements agent-browser get box # Get bounding box agent-browser get styles # Get computed styles @@ -199,6 +200,7 @@ agent-browser console --clear # Clear console log agent-browser errors # View page errors agent-browser errors --clear # Clear error log agent-browser highlight # Highlight element +agent-browser inspect # Open Chrome DevTools for the active page ``` ## Auth vault diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index ba343b0..1cfb9b3 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -129,6 +129,7 @@ agent-browser scroll down 500 --selector "div.content" # Scroll within a specif agent-browser get text @e1 # Get element text agent-browser get url # Get current URL agent-browser get title # Get page title +agent-browser get cdp-url # Get CDP WebSocket URL # Wait agent-browser wait @e1 # Wait for element @@ -308,6 +309,7 @@ The `scale` parameter (3rd argument) sets `window.devicePixelRatio` without chan ```bash agent-browser --headed open https://example.com agent-browser highlight @e1 # Highlight element +agent-browser inspect # Open Chrome DevTools for the active page agent-browser record start demo.webm # Record session agent-browser profiler start # Start Chrome DevTools profiling agent-browser profiler stop trace.json # Stop and save profile (path optional) diff --git a/skills/agent-browser/references/commands.md b/skills/agent-browser/references/commands.md index 33aadb7..383a748 100644 --- a/skills/agent-browser/references/commands.md +++ b/skills/agent-browser/references/commands.md @@ -58,6 +58,7 @@ agent-browser get value @e1 # Get input value agent-browser get attr @e1 href # Get attribute agent-browser get title # Get page title agent-browser get url # Get current URL +agent-browser get cdp-url # Get CDP WebSocket URL agent-browser get count ".item" # Count matching elements agent-browser get box @e1 # Get bounding box agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.) @@ -246,6 +247,7 @@ agent-browser console --clear # Clear console agent-browser errors # View page errors agent-browser errors --clear # Clear errors agent-browser highlight @e1 # Highlight element +agent-browser inspect # Open Chrome DevTools for this session agent-browser trace start # Start recording trace agent-browser trace stop trace.zip # Stop and save trace agent-browser profiler start # Start Chrome DevTools profiling diff --git a/src/actions.ts b/src/actions.ts index 7b3100c..0addd56 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +import { exec } from 'node:child_process'; import type { Page, Frame } from 'playwright-core'; import { mkdirSync } from 'node:fs'; import type { BrowserManager, ScreencastFrame } from './browser.js'; @@ -431,6 +432,10 @@ async function dispatchAction(command: Command, browser: BrowserManager): Promis return await handleReload(command, browser); case 'url': return await handleUrl(command, browser); + case 'cdp_url': + return handleCdpUrl(command, browser); + case 'inspect': + return await handleInspect(command, browser); case 'title': return await handleTitle(command, browser); case 'getattribute': @@ -1577,6 +1582,73 @@ async function handleUrl( return successResponse(command.id, { url: page.url() }); } +function handleCdpUrl(command: Command & { action: 'cdp_url' }, browser: BrowserManager): Response { + const cdpUrl = browser.getCdpUrl(); + if (!cdpUrl) { + return errorResponse(command.id, 'CDP URL not available (browser may not be launched)'); + } + return successResponse(command.id, { cdpUrl }); +} + +async function handleInspect( + command: Command & { action: 'inspect' }, + browser: BrowserManager +): Promise { + const cdpUrl = browser.getCdpUrl(); + if (!cdpUrl) { + return errorResponse(command.id, 'CDP URL not available (browser may not be launched)'); + } + + // Shut down any existing inspect server so we always target the current page + browser.stopInspectServer(); + + const stripped = cdpUrl.replace(/^(wss?|https?):\/\//, ''); + const hostPort = stripped.split('/')[0]; + + // Get the target ID so the inspect server can create its own dedicated CDP session + const page = browser.getPage(); + const context = page.context(); + const tmpCdp = await context.newCDPSession(page); + let targetId = ''; + try { + const info: any = await tmpCdp.send('Target.getTargetInfo' as any); + targetId = info?.targetInfo?.targetId || ''; + } catch (err) { + console.error('[inspect] getTargetInfo failed:', err); + } + await tmpCdp.detach(); + + if (!targetId) { + return errorResponse(command.id, 'Could not determine target ID for active page'); + } + + const { InspectServer } = await import('./inspect-server.js'); + const server = new InspectServer({ + chromeHostPort: hostPort, + targetId, + chromeWsUrl: cdpUrl, + }); + await server.start(); + browser.setInspectServer(server); + + const url = `http://127.0.0.1:${server.port}`; + openUrlInBrowser(url); + return successResponse(command.id, { opened: true, url }); +} + +function openUrlInBrowser(url: string): void { + const platform = process.platform; + const cmd = + platform === 'darwin' + ? `open "${url}"` + : platform === 'win32' + ? `start "" "${url}"` + : `xdg-open "${url}"`; + exec(cmd, (err) => { + if (err) console.error('[inspect] Failed to open browser:', err.message); + }); +} + async function handleTitle( command: Command & { action: 'title' }, browser: BrowserManager diff --git a/src/browser.ts b/src/browser.ts index fb03586..c3450af 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -19,6 +19,7 @@ import os from 'node:os'; import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs'; import { writeFile, mkdir } from 'node:fs/promises'; import type { LaunchCommand, TraceEvent } from './types.js'; +import type { InspectServer } from './inspect-server.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { safeHeaderMerge } from './state-utils.js'; import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js'; @@ -96,6 +97,7 @@ interface PageError { export class BrowserManager { private browser: Browser | null = null; private cdpEndpoint: string | null = null; // stores port number or full URL + private resolvedWsUrl: string | null = null; private isPersistentContext: boolean = false; private browserbaseSessionId: string | null = null; private browserbaseApiKey: string | null = null; @@ -119,6 +121,19 @@ export class BrowserManager { private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; private downloadPath: string | null = null; private allowedDomains: string[] = []; + private inspectServer: InspectServer | null = null; + + stopInspectServer(): void { + if (this.inspectServer) { + this.inspectServer.stop(); + this.inspectServer = null; + } + } + + setInspectServer(server: InspectServer): void { + this.stopInspectServer(); + this.inspectServer = server; + } /** * Set the persistent color scheme preference. @@ -167,6 +182,18 @@ export class BrowserManager { return this.browser !== null || this.isPersistentContext; } + getCdpUrl(): string | null { + if (this.resolvedWsUrl) return this.resolvedWsUrl; + if (this.cdpEndpoint?.startsWith('ws://') || this.cdpEndpoint?.startsWith('wss://')) { + return this.cdpEndpoint; + } + try { + return (this.browser as any)?.wsEndpoint?.() ?? null; + } catch { + return null; + } + } + /** * Get enhanced snapshot with refs and cache the ref map */ @@ -1402,6 +1429,7 @@ export class BrowserManager { ...(this.downloadPath && { downloadsPath: this.downloadPath }), }); this.cdpEndpoint = null; + this.resolvedWsUrl = null; // Check for auto-load state file (supports encrypted files) let storageState: @@ -1557,6 +1585,23 @@ export class BrowserManager { this.browser = browser; this.cdpEndpoint = cdpEndpoint; + let resolvedWs: string | null = null; + try { + resolvedWs = (browser as any).wsEndpoint?.() ?? null; + } catch (err) { + console.error('[inspect] wsEndpoint() failed:', err); + } + if (!resolvedWs && (cdpUrl.startsWith('http://') || cdpUrl.startsWith('https://'))) { + try { + const resp = await fetch(`${cdpUrl}/json/version`); + const info: any = await resp.json(); + resolvedWs = info.webSocketDebuggerUrl ?? null; + } catch (err) { + console.error('[inspect] /json/version fetch failed:', err); + } + } + this.resolvedWsUrl = resolvedWs; + for (const context of contexts) { context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); @@ -2471,6 +2516,8 @@ export class BrowserManager { * Close the browser and clean up */ async close(): Promise { + this.stopInspectServer(); + // Stop recording if active (saves video) if (this.recordingContext) { await this.stopRecording(); @@ -2551,6 +2598,7 @@ export class BrowserManager { this.pages = []; this.contexts = []; this.cdpEndpoint = null; + this.resolvedWsUrl = null; this.browserbaseSessionId = null; this.browserbaseApiKey = null; this.browserUseSessionId = null; diff --git a/src/inspect-server.test.ts b/src/inspect-server.test.ts new file mode 100644 index 0000000..534861f --- /dev/null +++ b/src/inspect-server.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { injectSessionId, stripSessionId } from './inspect-server.js'; + +describe('injectSessionId', () => { + it('should inject sessionId into a command', () => { + const input = '{"id":1,"method":"DOM.getDocument"}'; + const result = JSON.parse(injectSessionId(input, 'abc123')); + expect(result.sessionId).toBe('abc123'); + expect(result.method).toBe('DOM.getDocument'); + expect(result.id).toBe(1); + }); + + it('should inject sessionId into an empty object', () => { + const result = JSON.parse(injectSessionId('{}', 'abc')); + expect(result.sessionId).toBe('abc'); + }); +}); + +describe('stripSessionId', () => { + it('should remove sessionId from a message', () => { + const input = '{"id":1,"result":{},"sessionId":"abc123"}'; + const result = JSON.parse(stripSessionId(input)); + expect(result.sessionId).toBeUndefined(); + expect(result.id).toBe(1); + }); +}); + +describe('inject then strip roundtrip', () => { + it('should return the original message after inject + strip', () => { + const input = '{"id":42,"method":"Runtime.evaluate"}'; + const injected = injectSessionId(input, 'sess1'); + const stripped = stripSessionId(injected); + expect(JSON.parse(stripped)).toEqual(JSON.parse(input)); + }); +}); diff --git a/src/inspect-server.ts b/src/inspect-server.ts new file mode 100644 index 0000000..f4f209c --- /dev/null +++ b/src/inspect-server.ts @@ -0,0 +1,240 @@ +import http from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; + +export interface InspectServerOptions { + chromeHostPort: string; + targetId: string; + chromeWsUrl: string; +} + +let nextAttachId = -1000; + +export function injectSessionId(json: string, sessionId: string): string { + const msg = JSON.parse(json); + msg.sessionId = sessionId; + return JSON.stringify(msg); +} + +export function stripSessionId(json: string): string { + const msg = JSON.parse(json); + delete msg.sessionId; + return JSON.stringify(msg); +} + +// The Node.js path opens its own WebSocket to Chrome rather than sharing +// Playwright's internal connection. This avoids interfering with Playwright's +// CDP session management. The Rust/native path takes the opposite approach, +// sharing the daemon's existing browser-level WebSocket via InspectProxyHandle. +export class InspectServer { + private httpServer: http.Server; + private wss: WebSocketServer; + private chromeWs: WebSocket | null = null; + private sessions = new Map(); + private pendingAttaches = new Map void>(); + private _port: number = 0; + + constructor(private options: InspectServerOptions) { + this.httpServer = http.createServer(this.handleHttp.bind(this)); + this.wss = new WebSocketServer({ server: this.httpServer, path: '/ws' }); + this.wss.on('connection', this.handleWsConnection.bind(this)); + } + + get port(): number { + return this._port; + } + + async start(): Promise { + await this.connectChrome(); + return new Promise((resolve, reject) => { + this.httpServer.listen(0, '127.0.0.1', () => { + const addr = this.httpServer.address(); + if (addr && typeof addr !== 'string') { + this._port = addr.port; + } + resolve(); + }); + this.httpServer.on('error', reject); + }); + } + + stop(): void { + for (const [sessionId, devtoolsWs] of this.sessions) { + this.detachSession(sessionId); + devtoolsWs.close(); + } + this.sessions.clear(); + this.chromeWs?.close(); + this.chromeWs = null; + this.wss.close(); + this.httpServer.close(); + } + + private connectChrome(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.options.chromeWsUrl); + ws.on('open', () => { + this.chromeWs = ws; + resolve(); + }); + ws.on('error', (err) => { + if (!this.chromeWs) { + reject(new Error(`Chrome WebSocket connection failed: ${err.message}`)); + } else { + console.error('[inspect] Chrome WebSocket error:', err.message); + for (const devtoolsWs of this.sessions.values()) { + devtoolsWs.close(); + } + this.sessions.clear(); + } + }); + ws.on('close', () => { + this.chromeWs = null; + for (const devtoolsWs of this.sessions.values()) { + devtoolsWs.close(); + } + this.sessions.clear(); + }); + ws.on('message', (data) => this.handleChromeMessage(data)); + }); + } + + private handleChromeMessage(data: unknown): void { + try { + const text = String(data); + const msg = JSON.parse(text); + + // Check if this is a response to a pending attachToTarget request + if (msg.id != null && msg.id < 0) { + const resolve = this.pendingAttaches.get(msg.id); + if (resolve) { + this.pendingAttaches.delete(msg.id); + resolve(msg.result?.sessionId ?? null); + return; + } + } + + // Route session-scoped messages to the correct DevTools client + const sessionId: string | undefined = msg.sessionId; + if (!sessionId) return; + + const devtoolsWs = this.sessions.get(sessionId); + if (!devtoolsWs || devtoolsWs.readyState !== WebSocket.OPEN) return; + + devtoolsWs.send(stripSessionId(text)); + } catch (err) { + console.error('[inspect] Chrome message handling error:', err); + } + } + + private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.url === '/' || req.url === '') { + const location = `http://${this.options.chromeHostPort}/devtools/devtools_app.html?ws=127.0.0.1:${this._port}/ws`; + res.writeHead(302, { Location: location, 'Content-Type': 'text/html' }); + res.end(`Redirecting to ${location}`); + return; + } + res.writeHead(404); + res.end(); + } + + private handleWsConnection(devtoolsWs: WebSocket): void { + if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) { + devtoolsWs.close(); + return; + } + + const attachId = nextAttachId--; + const attachMsg = JSON.stringify({ + id: attachId, + method: 'Target.attachToTarget', + params: { targetId: this.options.targetId, flatten: true }, + }); + + // Track the session ID once attach completes; closed by close/error handlers + // that are registered immediately (before the async attach resolves) so + // early disconnects still trigger cleanup. + let sessionId: string | null = null; + + devtoolsWs.on('close', () => { + if (sessionId) { + this.sessions.delete(sessionId); + this.detachSession(sessionId); + } + }); + + devtoolsWs.on('error', () => { + if (sessionId) { + this.sessions.delete(sessionId); + this.detachSession(sessionId); + } + devtoolsWs.close(); + }); + + const messageBuffer: string[] = []; + + devtoolsWs.on('message', (data) => { + if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return; + const text = String(data); + if (!sessionId) { + messageBuffer.push(text); + return; + } + try { + this.chromeWs.send(injectSessionId(text, sessionId)); + } catch (err) { + console.error('[inspect] DevTools message forwarding error:', err); + } + }); + + const attachPromise = new Promise((resolve) => { + this.pendingAttaches.set(attachId, resolve); + this.chromeWs!.send(attachMsg); + setTimeout(() => { + if (this.pendingAttaches.has(attachId)) { + this.pendingAttaches.delete(attachId); + resolve(null); + } + }, 5000); + }); + + attachPromise.then((sid) => { + if (!sid) { + console.error('[inspect] Failed to attach to target'); + devtoolsWs.close(); + return; + } + + if (devtoolsWs.readyState !== WebSocket.OPEN) { + this.detachSession(sid); + return; + } + + sessionId = sid; + this.sessions.set(sid, devtoolsWs); + + for (const buffered of messageBuffer) { + try { + this.chromeWs!.send(injectSessionId(buffered, sid)); + } catch (err) { + console.error('[inspect] DevTools message forwarding error:', err); + } + } + messageBuffer.length = 0; + }); + } + + private detachSession(sessionId: string): void { + if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return; + const detachId = nextAttachId--; + const detachMsg = JSON.stringify({ + id: detachId, + method: 'Target.detachFromTarget', + params: { sessionId }, + }); + try { + this.chromeWs.send(detachMsg); + } catch (err) { + console.error('[inspect] Failed to detach session:', err); + } + } +} diff --git a/src/types.ts b/src/types.ts index 4d88f61..cbe70f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -296,6 +296,14 @@ export interface UrlCommand extends BaseCommand { action: 'url'; } +export interface CdpUrlCommand extends BaseCommand { + action: 'cdp_url'; +} + +export interface InspectCommand extends BaseCommand { + action: 'inspect'; +} + export interface TitleCommand extends BaseCommand { action: 'title'; } @@ -946,6 +954,8 @@ export type Command = | ForwardCommand | ReloadCommand | UrlCommand + | CdpUrlCommand + | InspectCommand | TitleCommand | GetAttributeCommand | GetTextCommand