diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 8e9ccaed..b1b6cb55 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -571,6 +571,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -601,9 +607,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1605,6 +1613,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1624,12 +1633,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.5", ] @@ -2055,6 +2066,19 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -2351,6 +2375,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8a90d716..899c9426 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,7 +22,7 @@ futures-util = "0.3" url = "2" uuid = { version = "1", features = ["v4"] } image = "0.25" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] } sha2 = "0.10" aes-gcm = "0.10" async-trait = "0.1" diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 45e30a21..f8e3d98a 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -32,6 +32,7 @@ use super::state; use super::storage; use super::stream::{self, StreamServer}; use super::tracing::{self as native_tracing, TracingState}; +use super::tauri_backend::{TauriBackend, TAURI_UNSUPPORTED_ACTIONS}; use super::webdriver::appium::AppiumManager; use super::webdriver::backend::{BrowserBackend, WebDriverBackend, WEBDRIVER_UNSUPPORTED_ACTIONS}; use super::webdriver::ios; @@ -107,6 +108,7 @@ pub struct FetchPausedRequest { pub enum BackendType { Cdp, WebDriver, + Tauri, } #[derive(Debug, Clone, Copy, Default)] @@ -121,6 +123,7 @@ pub struct DaemonState { pub appium: Option, pub safari_driver: Option, pub webdriver_backend: Option, + pub tauri_backend: Option, pub backend_type: BackendType, pub ref_map: RefMap, pub domain_filter: Arc>>, @@ -163,6 +166,7 @@ impl DaemonState { appium: None, safari_driver: None, webdriver_backend: None, + tauri_backend: None, backend_type: BackendType::Cdp, ref_map: RefMap::new(), domain_filter: Arc::new(RwLock::new( @@ -765,8 +769,10 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { | "device_list" ); if !skip_launch { - // Check if existing connection is stale and needs re-launch - let needs_launch = if let Some(ref mgr) = state.browser { + // Tauri backend manages its own connection — skip browser auto-launch + let needs_launch = if state.tauri_backend.is_some() { + false + } else if let Some(ref mgr) = state.browser { !mgr.is_connection_alive().await } else { true @@ -806,6 +812,19 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { ); } + // Tauri backend: reject unsupported actions + if matches!(state.backend_type, BackendType::Tauri) + && TAURI_UNSUPPORTED_ACTIONS.contains(&action) + { + return error_response( + &id, + &format!( + "Action '{}' is not supported on the Tauri MCP backend", + action + ), + ); + } + let result = match action { "launch" => handle_launch(cmd, state).await, "navigate" => handle_navigate(cmd, state).await, @@ -1190,6 +1209,9 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result { return launch_safari(cmd, state).await; } + "tauri" => { + return launch_tauri(cmd, state).await; + } _ => { let (ws_url, provider_session) = providers::connect_provider(provider).await?; match BrowserManager::connect_cdp(&ws_url).await { @@ -1392,6 +1414,47 @@ async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result Result { + let port: u16 = cmd + .get("port") + .and_then(|v| v.as_u64()) + .map(|p| p as u16) + .or_else(|| { + env::var("AGENT_BROWSER_TAURI_PORT") + .ok() + .and_then(|s| s.parse().ok()) + }) + .unwrap_or(9876); + + let host = cmd + .get("host") + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1"); + + // Security: only allow loopback hosts — Tauri MCP uses plaintext HTTP + // and may transmit sensitive page content / form values. + if host != "127.0.0.1" && host != "localhost" && host != "::1" { + return Err(format!( + "Tauri provider only allows loopback hosts (127.0.0.1, localhost, ::1); got: {host}" + )); + } + + let backend = TauriBackend::new(host, port); + backend.connect().await?; + + state.tauri_backend = Some(backend); + state.backend_type = BackendType::Tauri; + state.reset_input_state(); + + Ok(json!({ + "launched": true, + "provider": "tauri", + "host": host, + "port": port, + "backend": "tauri", + })) +} + async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result { let url = cmd .get("url") @@ -1405,6 +1468,13 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result Result Result { + if state.tauri_backend.is_some() { + // Tauri MCP plugin doesn't expose a "get current URL" tool + return Ok(json!({ "url": "" })); + } if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let url = wb.get_url().await?; @@ -1526,6 +1600,9 @@ fn open_url_in_browser(url: &str) { } async fn handle_title(state: &DaemonState) -> Result { + if state.tauri_backend.is_some() { + return Ok(json!({ "title": "" })); + } if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let title = wb.get_title().await?; @@ -1538,6 +1615,10 @@ async fn handle_title(state: &DaemonState) -> Result { } async fn handle_content(state: &DaemonState) -> Result { + if let Some(ref tb) = state.tauri_backend { + let tree = tb.snapshot(false).await?; + return Ok(json!({ "html": tree, "origin": "" })); + } if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { let html = wb.get_content().await?; @@ -1575,6 +1656,14 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result Result { + // Tauri backend: send close tool call and clean up + if let Some(ref mut tb) = state.tauri_backend { + let _ = tb.close().await; + state.tauri_backend = None; + state.backend_type = BackendType::Cdp; // Reset to default + return Ok(json!({ "closed": true })); + } + if let Some(ref mgr) = state.browser { if let Some(ref session_name) = state.session_name { if let Ok(session_id) = mgr.active_session_id() { @@ -1633,6 +1722,18 @@ async fn handle_close(state: &mut DaemonState) -> Result { // --------------------------------------------------------------------------- async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result { + // Tauri backend path + if let Some(ref tb) = state.tauri_backend { + let interactive = cmd + .get("interactive") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let tree_str = tb.snapshot(interactive).await?; + let tree_value: Value = serde_json::from_str(&tree_str) + .unwrap_or(Value::String(tree_str)); + return Ok(json!({ "snapshot": tree_value, "origin": "", "refs": {} })); + } + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); @@ -1689,6 +1790,12 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result Result Result` → JSON-RPC 2.0 request body +//! 3. Server sends JSON-RPC response on the SSE stream as a `message` event + +use async_trait::async_trait; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::{mpsc, Mutex}; + +use super::webdriver::backend::BrowserBackend; + +// --------------------------------------------------------------------------- +// MCP protocol types (minimal set needed for the provider) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct JsonRpcRequest { + jsonrpc: &'static str, + id: u64, + method: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct CallToolResult { + content: Vec, + #[serde(rename = "isError")] + is_error: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct CallToolContent { + text: String, +} + +// --------------------------------------------------------------------------- +// SSE event parser +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct SseEvent { + event_type: String, + data: String, +} + +fn parse_sse_block(block: &str) -> Option { + let mut event_type = "message".to_string(); + let mut data_lines: Vec<&str> = Vec::new(); + + for line in block.lines() { + if line.starts_with(':') { + // SSE comment — ignore + } else if let Some(rest) = line.strip_prefix("event:") { + event_type = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim()); + } + } + + if data_lines.is_empty() { + return None; + } + + Some(SseEvent { + event_type, + data: data_lines.join("\n"), + }) +} + +// --------------------------------------------------------------------------- +// Tauri MCP backend +// --------------------------------------------------------------------------- + +struct Session { + post_url: String, + event_rx: mpsc::UnboundedReceiver, +} + +/// Backend that connects to a Tauri app's MCP server for AI-driven UI testing. +pub struct TauriBackend { + host: String, + port: u16, + client: reqwest::Client, + session: Arc>>, + next_id: Arc, +} + +/// Actions not supported by the Tauri MCP backend. +/// Actions not supported by the Tauri MCP backend. +/// +/// These must match the action names used in `execute_command`'s match arms +/// in `actions.rs` — NOT abbreviated aliases. +pub const TAURI_UNSUPPORTED_ACTIONS: &[&str] = &[ + // CDP-only features + "screencast_start", + "screencast_stop", + "trace_start", + "trace_stop", + "profiler_start", + "profiler_stop", + "route", + "unroute", + "expose", + "addscript", + "addinitscript", + "network", + "har_start", + "har_stop", + // Interaction actions not yet supported via MCP + "dblclick", + "hover", + "scroll", + "select", + "check", + "uncheck", + "wait", + "type", + "press", + "evaluate", + // Query actions not yet supported via MCP + "gettext", + "getattribute", + "isvisible", + "isenabled", + "ischecked", + // Navigation actions the MCP plugin doesn't implement + "back", + "forward", + "reload", + // Storage/cookie actions — use the dispatcher's actual action names + "cookies_get", + "cookies_set", + "cookies_clear", + "storage_get", + "storage_set", + "storage_clear", + // CDP introspection + "cdp_url", + "inspect", +]; + +impl TauriBackend { + pub fn new(host: &str, port: u16) -> Self { + Self { + host: host.to_string(), + port, + client: reqwest::Client::new(), + session: Arc::new(Mutex::new(None)), + next_id: Arc::new(AtomicU64::new(1)), + } + } + + fn base_url(&self) -> String { + format!("http://{}:{}", self.host, self.port) + } + + /// Connect to the Tauri MCP server's SSE endpoint and initialize the session. + pub async fn connect(&self) -> Result<(), String> { + let sse_url = format!("{}/sse", self.base_url()); + + let response = self + .client + .get(&sse_url) + .send() + .await + .map_err(|e| { + if e.is_connect() { + format!( + "Cannot connect to Tauri MCP server at {}:{}. \ + Make sure the Tauri app is running with the \ + tauri-plugin-agent-test plugin loaded.", + self.host, self.port + ) + } else { + format!("HTTP error connecting to Tauri MCP server: {e}") + } + })?; + + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Background task: stream SSE bytes → parsed events + let mut byte_stream = response.bytes_stream(); + tokio::spawn(async move { + let mut buf = String::new(); + while let Some(chunk) = byte_stream.next().await { + match chunk { + Ok(bytes) => { + if let Ok(text) = std::str::from_utf8(&bytes) { + buf.push_str(text); + while let Some(pos) = buf.find("\n\n") { + let block = buf[..pos].to_string(); + buf = buf[pos + 2..].to_string(); + if let Some(event) = parse_sse_block(&block) { + let _ = tx.send(event); + } + } + } + } + Err(_) => break, + } + } + }); + + // Wait for the `endpoint` event + let endpoint_path = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + match rx.recv().await { + Some(ev) if ev.event_type == "endpoint" => return Ok(ev.data), + Some(_) => continue, + None => { + return Err("SSE stream closed before endpoint event".to_string()); + } + } + } + }) + .await + .map_err(|_| { + format!( + "Timeout waiting for endpoint event from {}:{}", + self.host, self.port + ) + })??; + + let post_url = format!("{}{}", self.base_url(), endpoint_path); + + *self.session.lock().await = Some(Session { + post_url, + event_rx: rx, + }); + + // Send MCP initialize + self.send_rpc("initialize", Some(serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "agent-browser", + "version": env!("CARGO_PKG_VERSION") + } + }))) + .await?; + + Ok(()) + } + + async fn send_rpc(&self, method: &str, params: Option) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let request = JsonRpcRequest { + jsonrpc: "2.0", + id, + method: method.to_string(), + params, + }; + + // Lock scope 1: get the POST URL and send the request. + let post_url = { + let session = self.session.lock().await; + let sess = session + .as_ref() + .ok_or("Not connected — call connect() first")?; + sess.post_url.clone() + }; + + self.client + .post(&post_url) + .json(&request) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| format!("HTTP error: {e}"))?; + + // Lock scope 2: read the SSE stream for the matching response. + let response_value = { + let mut session = self.session.lock().await; + let sess = session + .as_mut() + .ok_or("Not connected — session dropped")?; + + tokio::time::timeout( + std::time::Duration::from_secs(30), + async { + let expected_id = serde_json::json!(id); + loop { + match sess.event_rx.recv().await { + Some(ev) if ev.event_type == "message" => { + if let Ok(v) = serde_json::from_str::(&ev.data) { + if v.get("id") == Some(&expected_id) { + return Ok(v); + } + } + } + Some(_) => continue, + None => { + return Err( + "SSE stream closed while waiting for response".to_string(), + ); + } + } + } + }, + ) + .await + .map_err(|_| format!("Timeout waiting for response to '{method}'"))?? + }; + + Ok(response_value) + } + + async fn call_tool(&self, name: &str, arguments: Value) -> Result { + let response = self + .send_rpc( + "tools/call", + Some(serde_json::json!({ "name": name, "arguments": arguments })), + ) + .await?; + + if let Some(error) = response.get("error") { + return Err(format!( + "MCP error: {}", + error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + )); + } + + let result = response + .get("result") + .ok_or("No result in tools/call response")?; + + serde_json::from_value::(result.clone()) + .map_err(|e| format!("Failed to parse CallToolResult: {e}")) + } + + /// Call a tool and return the text content, or an error. + async fn call_tool_text(&self, name: &str, arguments: Value) -> Result { + let result = self.call_tool(name, arguments).await?; + if result.is_error { + return Err(result + .content + .first() + .map(|c| c.text.clone()) + .unwrap_or_else(|| "Unknown MCP error".to_string())); + } + Ok(result + .content + .first() + .map(|c| c.text.clone()) + .unwrap_or_default()) + } +} + +#[async_trait] +impl BrowserBackend for TauriBackend { + async fn navigate(&self, url: &str) -> Result<(), String> { + self.call_tool_text("navigate", serde_json::json!({ "url": url })) + .await?; + Ok(()) + } + + async fn get_url(&self) -> Result { + // MCP plugin doesn't have a dedicated url tool; return empty for now + Ok(String::new()) + } + + async fn get_title(&self) -> Result { + // MCP plugin doesn't have a dedicated title tool + Ok(String::new()) + } + + async fn get_content(&self) -> Result { + // Snapshot returns the DOM tree — use that as "content" + self.call_tool_text("snapshot", serde_json::json!({})).await + } + + async fn evaluate(&self, _script: &str) -> Result { + Err("evaluate is not supported on the Tauri MCP backend".to_string()) + } + + async fn screenshot(&self) -> Result { + self.call_tool_text("screenshot", serde_json::json!({})) + .await + } + + async fn click(&self, selector: &str) -> Result<(), String> { + // In Tauri MCP, "click" takes a @ref, not a CSS selector. + // The ref is passed as the selector by the upstream dispatch. + self.call_tool_text("click", serde_json::json!({ "ref": selector })) + .await?; + Ok(()) + } + + async fn fill(&self, selector: &str, value: &str) -> Result<(), String> { + self.call_tool_text( + "fill", + serde_json::json!({ "ref": selector, "value": value }), + ) + .await?; + Ok(()) + } + + async fn close(&mut self) -> Result<(), String> { + // Try to send close tool call, ignore errors (server may already be down) + let _ = self.call_tool_text("close", serde_json::json!({})).await; + *self.session.lock().await = None; + Ok(()) + } + + async fn back(&self) -> Result<(), String> { + Err("back is not supported on the Tauri MCP backend".to_string()) + } + + async fn forward(&self) -> Result<(), String> { + Err("forward is not supported on the Tauri MCP backend".to_string()) + } + + async fn reload(&self) -> Result<(), String> { + Err("reload is not supported on the Tauri MCP backend".to_string()) + } + + async fn get_cookies(&self) -> Result { + Err("get_cookies is not supported on the Tauri MCP backend".to_string()) + } + + fn backend_type(&self) -> &str { + "tauri" + } +} + +// --------------------------------------------------------------------------- +// Snapshot helper — returns the snapshot text for actions.rs +// --------------------------------------------------------------------------- + +impl TauriBackend { + /// Take a snapshot of the Tauri webview DOM. + pub async fn snapshot(&self, interactive: bool) -> Result { + self.call_tool_text( + "snapshot", + serde_json::json!({ "interactive_only": interactive }), + ) + .await + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sse_block_endpoint() { + let block = "event: endpoint\ndata: /message?sessionId=abc-123"; + let event = parse_sse_block(block).unwrap(); + assert_eq!(event.event_type, "endpoint"); + assert_eq!(event.data, "/message?sessionId=abc-123"); + } + + #[test] + fn test_parse_sse_block_message() { + let block = "event: message\ndata: {\"id\":1,\"result\":{}}"; + let event = parse_sse_block(block).unwrap(); + assert_eq!(event.event_type, "message"); + assert!(event.data.contains("\"id\":1")); + } + + #[test] + fn test_parse_sse_block_comment() { + let block = ": keep-alive"; + assert!(parse_sse_block(block).is_none()); + } + + #[test] + fn test_parse_sse_block_default_event_type() { + let block = "data: hello"; + let event = parse_sse_block(block).unwrap(); + assert_eq!(event.event_type, "message"); + assert_eq!(event.data, "hello"); + } + + #[test] + fn test_tauri_backend_type() { + let backend = TauriBackend::new("127.0.0.1", 9876); + assert_eq!(backend.backend_type(), "tauri"); + } + + #[test] + fn test_tauri_unsupported_actions_includes_cdp_only() { + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"screencast_start")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"har_start")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"cdp_url")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"inspect")); + } + + #[test] + fn test_tauri_unsupported_actions_uses_dispatcher_names() { + // Must match execute_command's match arms, not abbreviated names + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"cookies_get")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"cookies_set")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"storage_get")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"back")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"forward")); + assert!(TAURI_UNSUPPORTED_ACTIONS.contains(&"reload")); + } + + #[test] + fn test_tauri_unsupported_actions_excludes_core() { + // Core actions supported by the Tauri MCP plugin + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"snapshot")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"click")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"fill")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"screenshot")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"navigate")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"close")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"launch")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"url")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"title")); + assert!(!TAURI_UNSUPPORTED_ACTIONS.contains(&"content")); + } + + #[test] + fn test_base_url() { + let backend = TauriBackend::new("127.0.0.1", 9876); + assert_eq!(backend.base_url(), "http://127.0.0.1:9876"); + } + + #[test] + fn test_base_url_custom_port() { + let backend = TauriBackend::new("localhost", 3000); + assert_eq!(backend.base_url(), "http://localhost:3000"); + } +}