inspect (#736)
* inspect * fixes * improvements * fixes * fixes * improvements * fix null cdp url * fix rust reader loop * improvements * improvements * fixes
This commit is contained in:
+20
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user