* inspect

* fixes

* improvements

* fixes

* fixes

* improvements

* fix null cdp url

* fix rust reader loop

* improvements

* improvements

* fixes
This commit is contained in:
Chris Tate
2026-03-12 12:01:40 -05:00
committed by GitHub
parent f2d4089284
commit 315d191606
17 changed files with 1081 additions and 3 deletions
+2
View File
@@ -131,6 +131,7 @@ agent-browser get value <sel> # Get input value
agent-browser get attr <sel> <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 <sel> # Count matching elements
agent-browser get box <sel> # Get bounding box
agent-browser get styles <sel> # 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 <sel> # Highlight element
agent-browser inspect # Open Chrome DevTools for the active page
agent-browser state save <path> # Save auth state
agent-browser state load <path> # Load auth state
agent-browser state list # List saved state files
+20 -2
View File
@@ -568,6 +568,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => 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<Value, ParseErro
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
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<Value, ParseError> {
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<Value, ParseError> {
}),
None => Err(ParseError::MissingArguments {
context: "get".to_string(),
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
usage: "get <text|html|value|attr|url|title|count|box|styles|cdp-url> [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");
}
}
+53
View File
@@ -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<HarEntry>,
pub confirm_actions: Option<ConfirmActions>,
pub inspect_server: Option<InspectServer>,
pub routes: Vec<RouteEntry>,
pub tracked_requests: Vec<TrackedRequest>,
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<Value, String> {
Ok(json!({ "url": url }))
}
fn handle_cdp_url(state: &DaemonState) -> Result<Value, String> {
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<Value, String> {
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<std::process::Child, std::io::Error> = 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<Value, String> {
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<Value, String> {
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 }))
}
+24
View File
@@ -161,6 +161,7 @@ impl BrowserProcess {
pub struct BrowserManager {
pub client: CdpClient,
browser_process: Option<BrowserProcess>,
ws_url: String,
pages: Vec<PageInfo>,
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()
+83
View File
@@ -12,6 +12,14 @@ use super::types::{CdpCommand, CdpEvent, CdpMessage};
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
/// 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<String>,
}
pub struct CdpClient {
ws_tx: Arc<
Mutex<
@@ -26,6 +34,7 @@ pub struct CdpClient {
next_id: AtomicU64,
pending: PendingMap,
event_tx: broadcast::Sender<CdpEvent>,
raw_tx: broadcast::Sender<RawCdpMessage>,
_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::<serde_json::Value>(&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<RawCdpMessage> {
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<P: serde::Serialize, R: serde::de::DeserializeOwned>(
&self,
method: &str,
@@ -160,4 +201,46 @@ impl CdpClient {
) -> Result<Value, String> {
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<tokio::net::TcpStream>,
>,
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<RawCdpMessage>,
}
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<RawCdpMessage> {
self.raw_tx.subscribe()
}
}
+83
View File
@@ -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:<port>, 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);
}
+360
View File
@@ -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<Self, String> {
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<InspectProxyHandle>,
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<InspectProxyHandle>,
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<tokio::net::TcpStream>,
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!(
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
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<InspectProxyHandle>,
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::<serde_json::Value>(&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::<serde_json::Value>(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::<serde_json::Value>(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);
}
}
+2
View File
@@ -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;
+43 -1
View File
@@ -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 <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height)
styles <selector> 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 <what> [selector]
text, html, value, attr <name>, title, url, count, box, styles
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
Check State: agent-browser is <what> <selector>
visible, enabled, checked
@@ -2392,6 +2433,7 @@ Debug:
console [--clear] View console logs
errors [--clear] View page errors
highlight <sel> Highlight element
inspect Open Chrome DevTools for the active page
Auth Vault:
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
+2
View File
@@ -44,6 +44,7 @@ agent-browser get value <sel> # Get input value
agent-browser get attr <sel> <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 <sel> # Count matching elements
agent-browser get box <sel> # Get bounding box
agent-browser get styles <sel> # 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 <sel> # Highlight element
agent-browser inspect # Open Chrome DevTools for the active page
```
## Auth vault
+2
View File
@@ -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)
@@ -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
+72
View File
@@ -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<Response> {
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
+48
View File
@@ -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<void> {
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;
+35
View File
@@ -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));
});
});
+240
View File
@@ -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<string, WebSocket>();
private pendingAttaches = new Map<number, (sessionId: string | null) => 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<void> {
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<void> {
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(`<html><body>Redirecting to <a href="${location}">${location}</a></body></html>`);
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<string | null>((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);
}
}
}
+10
View File
@@ -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