From 6f3c4e7778ca515cc2aab8c1f2e6a4977a85592f Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Mon, 2 Mar 2026 05:08:42 +0300 Subject: [PATCH] batch fixes --- Cargo.lock | 29 +- Cargo.toml | 2 +- crates/openfang-api/src/middleware.rs | 11 +- crates/openfang-api/src/routes.rs | 17 +- crates/openfang-api/static/index_body.html | 5 +- crates/openfang-kernel/src/kernel.rs | 25 + crates/openfang-runtime/Cargo.toml | 1 + crates/openfang-runtime/src/browser.rs | 1224 +++++++++++++---- crates/openfang-runtime/src/browser_bridge.py | 188 --- crates/openfang-runtime/src/model_catalog.rs | 13 + crates/openfang-runtime/src/tool_runner.rs | 92 +- crates/openfang-runtime/src/web_content.rs | 102 +- crates/openfang-types/src/config.rs | 10 +- crates/openfang-types/src/model_catalog.rs | 2 +- 14 files changed, 1224 insertions(+), 497 deletions(-) delete mode 100644 crates/openfang-runtime/src/browser_bridge.py diff --git a/Cargo.lock b/Cargo.lock index dfbb3da9..7778e359 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-trait", "axum", @@ -3902,7 +3902,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-trait", "axum", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.2.5" +version = "0.2.6" dependencies = [ "clap", "clap_complete", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.2.5" +version = "0.2.6" dependencies = [ "axum", "open", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.2.5" +version = "0.2.6" dependencies = [ "aes-gcm", "argon2", @@ -4014,7 +4014,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.2.5" +version = "0.2.6" dependencies = [ "chrono", "dashmap", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-trait", "chrono", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-trait", "chrono", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.2.5" +version = "0.2.6" dependencies = [ "chrono", "dirs 6.0.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "async-trait", @@ -4128,6 +4128,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-test", + "tokio-tungstenite 0.24.0", "tracing", "uuid", "wasmtime", @@ -4136,7 +4137,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.2.5" +version = "0.2.6" dependencies = [ "chrono", "hex", @@ -4158,7 +4159,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-trait", "chrono", @@ -4177,7 +4178,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-trait", "chrono", @@ -8789,7 +8790,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.2.5" +version = "0.2.6" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 7da1cfcd..b812dffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.2.6" +version = "0.2.7" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index 4fdf4b9d..56385217 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -119,15 +119,22 @@ pub async fn auth( return next.run(request).await; } - // Check Authorization: Bearer header + // Check Authorization: Bearer header, then fallback to X-API-Key let bearer_token = request .headers() .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")); + let api_token = bearer_token.or_else(|| { + request + .headers() + .get("x-api-key") + .and_then(|v| v.to_str().ok()) + }); + // SECURITY: Use constant-time comparison to prevent timing attacks. - let header_auth = bearer_token.map(|token| { + let header_auth = api_token.map(|token| { use subtle::ConstantTimeEq; if token.len() != api_key.len() { return false; diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 0565a50f..4c7161de 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -6195,14 +6195,25 @@ pub async fn test_provider( State(state): State>, Path(name): Path, ) -> impl IntoResponse { - let (env_var, base_url, key_required) = { + let (env_var, base_url, key_required, default_model) = { let catalog = state .kernel .model_catalog .read() .unwrap_or_else(|e| e.into_inner()); match catalog.get_provider(&name) { - Some(p) => (p.api_key_env.clone(), p.base_url.clone(), p.key_required), + Some(p) => { + // Find a default model for this provider to use in the test request + let model_id = catalog + .default_model_for_provider(&name) + .unwrap_or_default(); + ( + p.api_key_env.clone(), + p.base_url.clone(), + p.key_required, + model_id, + ) + } None => { return ( StatusCode::NOT_FOUND, @@ -6237,7 +6248,7 @@ pub async fn test_provider( Ok(driver) => { // Send a minimal completion request to test connectivity let test_req = openfang_runtime::llm_driver::CompletionRequest { - model: String::new(), // Driver will use default + model: default_model.clone(), messages: vec![openfang_types::message::Message::user("Hi")], tools: vec![], max_tokens: 1, diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html index 77404e69..e3494462 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -2462,7 +2462,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
- +
@@ -2489,6 +2489,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"] + diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 990eb924..65059c47 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -996,6 +996,7 @@ impl OpenFangKernel { if manifest.exec_policy.is_none() { manifest.exec_policy = Some(self.config.exec_policy.clone()); } + info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved"); // Overlay kernel default_model onto agent if no custom key/url is set. // This ensures agents respect the user's configured provider from `openfang init`. @@ -2808,6 +2809,18 @@ impl OpenFangKernel { if let Err(e) = self.kill_agent(agent_id) { warn!(agent = %agent_id, error = %e, "Failed to kill hand agent (may already be dead)"); } + } else { + // Fallback: if agent_id was never set (incomplete activation), search by hand tag + let hand_tag = format!("hand:{}", instance.hand_id); + for entry in self.registry.list() { + if entry.tags.contains(&hand_tag) { + if let Err(e) = self.kill_agent(entry.id) { + warn!(agent = %entry.id, error = %e, "Failed to kill orphaned hand agent"); + } else { + info!(agent_id = %entry.id, hand_id = %instance.hand_id, "Cleaned up orphaned hand agent"); + } + } + } } Ok(()) } @@ -4101,6 +4114,18 @@ impl OpenFangKernel { all_tools.retain(|t| !tool_blocklist.iter().any(|b| b == &t.name)); } + // Remove shell_exec from tool list if exec_policy won't allow it, + // so the LLM doesn't try to call a tool that will be blocked. + let exec_blocks_shell = entry.as_ref().is_some_and(|e| { + e.manifest + .exec_policy + .as_ref() + .is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Deny) + }); + if exec_blocks_shell { + all_tools.retain(|t| t.name != "shell_exec"); + } + let caps = self.capabilities.list(agent_id); // If agent has ToolAll, return all tools diff --git a/crates/openfang-runtime/Cargo.toml b/crates/openfang-runtime/Cargo.toml index 9b54f87b..6883478d 100644 --- a/crates/openfang-runtime/Cargo.toml +++ b/crates/openfang-runtime/Cargo.toml @@ -29,6 +29,7 @@ hex = { workspace = true } zeroize = { workspace = true } dashmap = { workspace = true } regex-lite = { workspace = true } +tokio-tungstenite = "0.24" [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/openfang-runtime/src/browser.rs b/crates/openfang-runtime/src/browser.rs index ecbe63d0..4bb0f2e7 100644 --- a/crates/openfang-runtime/src/browser.rs +++ b/crates/openfang-runtime/src/browser.rs @@ -1,32 +1,45 @@ -//! Browser automation via a Python Playwright bridge. +//! Native browser automation via Chrome DevTools Protocol (CDP). //! -//! Manages persistent browser sessions per agent, communicating with a Python -//! subprocess over JSON-line stdin/stdout protocol (same pattern as MCP stdio). +//! Direct WebSocket connection to Chromium. No Python, no Playwright. +//! Launches a Chromium process, connects over CDP WebSocket, and sends +//! JSON-RPC commands for navigation, interaction, screenshots, etc. //! //! # Security -//! - SSRF check runs in Rust *before* sending navigate commands to Python -//! - Bridge subprocess launched with `sandbox_command()` (cleared env) +//! - SSRF check runs in Rust before navigate commands //! - All page content wrapped with `wrap_external_content()` markers //! - Session limits: max concurrent, idle timeout, 1 per agent +//! - No subprocess bridge, no env leakage, no Python code execution use dashmap::DashMap; +use futures::stream::{SplitSink, SplitStream}; +use futures::{SinkExt, StreamExt}; use openfang_types::config::BrowserConfig; use serde::{Deserialize, Serialize}; -use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; -use std::process::{Child, ChildStdin, ChildStdout, Stdio}; -use std::sync::OnceLock; -use std::time::Instant; -use tokio::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::AsyncBufReadExt; +use tokio::sync::{oneshot, Mutex}; +use tokio_tungstenite::tungstenite::Message as WsMessage; use tracing::{debug, info, warn}; -/// Embedded Python bridge script (compiled into the binary). -const BRIDGE_SCRIPT: &str = include_str!("browser_bridge.py"); +type WsStream = + tokio_tungstenite::WebSocketStream>; -// ── Protocol types ────────────────────────────────────────────────────────── +// ── Constants ────────────────────────────────────────────────────────────── -/// Command sent from Rust to the Python bridge. -#[derive(Debug, Serialize)] +const CDP_CONNECT_TIMEOUT_SECS: u64 = 15; +const CDP_COMMAND_TIMEOUT_SECS: u64 = 30; +const PAGE_LOAD_POLL_INTERVAL_MS: u64 = 200; +const PAGE_LOAD_MAX_POLLS: u32 = 150; // 30 seconds +#[allow(dead_code)] +const MAX_CONTENT_CHARS: usize = 50_000; + +// ── Public types ─────────────────────────────────────────────────────────── + +/// Command sent to the browser. +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action")] pub enum BrowserCommand { Navigate { url: String }, @@ -35,74 +48,741 @@ pub enum BrowserCommand { Screenshot, ReadPage, Close, + Scroll { direction: String, amount: i32 }, + Wait { selector: String, timeout_ms: u64 }, + RunJs { expression: String }, + Back, } -/// Response received from the Python bridge. -#[derive(Debug, Deserialize)] +/// Response from a browser command. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserResponse { pub success: bool, pub data: Option, pub error: Option, } -// ── Session ───────────────────────────────────────────────────────────────── +impl BrowserResponse { + fn ok(data: serde_json::Value) -> Self { + Self { + success: true, + data: Some(data), + error: None, + } + } + fn err(msg: impl Into) -> Self { + Self { + success: false, + data: None, + error: Some(msg.into()), + } + } +} -/// A live browser session backed by a Python Playwright subprocess. +// ── CDP connection ───────────────────────────────────────────────────────── + +/// Low-level Chrome DevTools Protocol connection over WebSocket. +struct CdpConnection { + write: Arc>>, + pending: Arc>>>, + next_id: AtomicU64, + _reader_handle: tokio::task::JoinHandle<()>, +} + +impl CdpConnection { + /// Connect to a CDP WebSocket endpoint. + async fn connect(ws_url: &str) -> Result { + let (stream, _) = tokio::time::timeout( + Duration::from_secs(CDP_CONNECT_TIMEOUT_SECS), + tokio_tungstenite::connect_async(ws_url), + ) + .await + .map_err(|_| format!("CDP WebSocket connect timed out: {ws_url}"))? + .map_err(|e| format!("CDP WebSocket connect failed: {e}"))?; + + let (write, read) = stream.split(); + let write = Arc::new(Mutex::new(write)); + let pending: Arc>>> = + Arc::new(DashMap::new()); + + let reader_pending = Arc::clone(&pending); + let reader_handle = tokio::spawn(Self::reader_loop(read, reader_pending)); + + Ok(Self { + write, + pending, + next_id: AtomicU64::new(1), + _reader_handle: reader_handle, + }) + } + + /// Background task: read WebSocket messages and route responses. + async fn reader_loop( + mut read: SplitStream, + pending: Arc>>>, + ) { + while let Some(msg) = read.next().await { + let text = match msg { + Ok(WsMessage::Text(t)) => t.to_string(), + Ok(WsMessage::Close(_)) => break, + Err(e) => { + debug!("CDP WebSocket read error: {e}"); + break; + } + _ => continue, + }; + + let json: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + // Route response to waiting caller by id + if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { + if let Some((_, sender)) = pending.remove(&id) { + if let Some(error) = json.get("error") { + let msg = error["message"] + .as_str() + .unwrap_or("CDP error") + .to_string(); + let _ = sender.send(Err(msg)); + } else { + let result = json.get("result").cloned().unwrap_or(serde_json::Value::Null); + let _ = sender.send(Ok(result)); + } + } + } + // Events (method field, no id) are ignored for now. + // Future: handle Fetch.requestPaused for CDP-level SSRF. + } + } + + /// Send a CDP command and wait for the response. + async fn send( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.insert(id, tx); + + let msg = serde_json::json!({ "id": id, "method": method, "params": params }); + self.write + .lock() + .await + .send(WsMessage::Text(msg.to_string())) + .await + .map_err(|e| format!("CDP send failed: {e}"))?; + + match tokio::time::timeout(Duration::from_secs(CDP_COMMAND_TIMEOUT_SECS), rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("CDP response channel closed".to_string()), + Err(_) => { + self.pending.remove(&id); + Err("CDP command timed out".to_string()) + } + } + } + + /// Evaluate JavaScript in the browser page and return the value. + async fn run_js(&self, expression: &str) -> Result { + let result = self + .send( + "Runtime.evaluate", + serde_json::json!({ + "expression": expression, + "returnByValue": true, + "awaitPromise": true, + }), + ) + .await?; + + // Check for JS exceptions + if let Some(desc) = result + .get("exceptionDetails") + .and_then(|e| e.get("text")) + .and_then(|t| t.as_str()) + { + return Err(format!("JS error: {desc}")); + } + + Ok(result + .get("result") + .and_then(|r| r.get("value")) + .cloned() + .unwrap_or(serde_json::Value::Null)) + } +} + +impl Drop for CdpConnection { + fn drop(&mut self) { + self._reader_handle.abort(); + } +} + +// ── Browser session ──────────────────────────────────────────────────────── + +/// A live browser session: one Chromium process + one CDP connection per agent. struct BrowserSession { - child: Child, - stdin: ChildStdin, - stdout: BufReader, + process: tokio::process::Child, + cdp: CdpConnection, + #[allow(dead_code)] last_active: Instant, } impl BrowserSession { - /// Send a command and read the response. - fn send(&mut self, cmd: &BrowserCommand) -> Result { - let json = serde_json::to_string(cmd).map_err(|e| format!("Serialize error: {e}"))?; - self.stdin - .write_all(json.as_bytes()) - .map_err(|e| format!("Failed to write to bridge stdin: {e}"))?; - self.stdin - .write_all(b"\n") - .map_err(|e| format!("Failed to write newline: {e}"))?; - self.stdin - .flush() - .map_err(|e| format!("Failed to flush bridge stdin: {e}"))?; + /// Launch Chromium and establish a CDP connection. + async fn launch(config: &BrowserConfig) -> Result { + let chrome_path = find_chromium(config)?; + debug!(path = %chrome_path.display(), "Launching Chromium"); - let mut line = String::new(); - self.stdout - .read_line(&mut line) - .map_err(|e| format!("Failed to read bridge stdout: {e}"))?; - - if line.trim().is_empty() { - return Err("Bridge process closed unexpectedly".to_string()); + let mut args = vec![ + "--remote-debugging-port=0".to_string(), + "--no-first-run".to_string(), + "--no-default-browser-check".to_string(), + "--disable-extensions".to_string(), + "--disable-background-networking".to_string(), + "--disable-sync".to_string(), + "--disable-translate".to_string(), + "--disable-features=TranslateUI".to_string(), + "--metrics-recording-only".to_string(), + format!( + "--window-size={},{}", + config.viewport_width, config.viewport_height + ), + "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".to_string(), + "about:blank".to_string(), + ]; + if config.headless { + args.insert(0, "--headless=new".to_string()); + args.push("--disable-gpu".to_string()); } - self.last_active = Instant::now(); - serde_json::from_str(line.trim()) - .map_err(|e| format!("Failed to parse bridge response: {e}")) + let mut cmd = tokio::process::Command::new(&chrome_path); + cmd.args(&args); + cmd.stderr(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::null()); + cmd.stdin(std::process::Stdio::null()); + + // SECURITY: clear environment, pass only essentials + cmd.env_clear(); + for key in &[ + "PATH", + "HOME", + "USERPROFILE", + "SYSTEMROOT", + "TEMP", + "TMP", + "TMPDIR", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "DISPLAY", + "WAYLAND_DISPLAY", + ] { + if let Ok(val) = std::env::var(key) { + cmd.env(key, val); + } + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to launch Chromium at {}: {e}", chrome_path.display()))?; + + // Parse stderr for the DevTools WebSocket URL + let stderr = child.stderr.take().ok_or("No stderr from Chromium")?; + let ws_url = Self::read_devtools_url(stderr).await?; + debug!(ws_url = %ws_url, "Got CDP WebSocket URL"); + + // GET /json/list to find the page target + let port = ws_url + .split("://") + .nth(1) + .and_then(|s| s.split(':').nth(1)) + .and_then(|s| s.split('/').next()) + .ok_or("Cannot parse port from CDP URL")?; + let list_url = format!("http://127.0.0.1:{port}/json/list"); + + let page_ws = Self::find_page_ws(&list_url).await?; + debug!(page_ws = %page_ws, "Connecting to page"); + + let cdp = CdpConnection::connect(&page_ws).await?; + + // Enable required domains + let _ = cdp.send("Page.enable", serde_json::json!({})).await; + let _ = cdp.send("Runtime.enable", serde_json::json!({})).await; + + Ok(Self { + process: child, + cdp, + last_active: Instant::now(), + }) } - /// Kill the subprocess. - fn kill(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); + /// Read stderr until we find "DevTools listening on ws://...". + async fn read_devtools_url(stderr: tokio::process::ChildStderr) -> Result { + let reader = tokio::io::BufReader::new(stderr); + let mut lines = reader.lines(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(CDP_CONNECT_TIMEOUT_SECS); + + loop { + let line = tokio::time::timeout_at(deadline, lines.next_line()) + .await + .map_err(|_| { + "Timed out waiting for Chromium to start. Is Chrome/Chromium installed?" + .to_string() + })? + .map_err(|e| format!("Failed to read Chromium stderr: {e}"))?; + + match line { + Some(l) if l.contains("DevTools listening on") => { + let url = l + .split("DevTools listening on ") + .nth(1) + .ok_or("Malformed DevTools URL line")? + .trim() + .to_string(); + return Ok(url); + } + Some(_) => continue, + None => { + return Err( + "Chromium exited before printing DevTools URL. Is Chrome installed?" + .to_string(), + ); + } + } + } + } + + /// Fetch /json/list and find the page WebSocket URL. + async fn find_page_ws(list_url: &str) -> Result { + for attempt in 0..10 { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(300)).await; + } + let resp = match reqwest::get(list_url).await { + Ok(r) => r, + Err(_) => continue, + }; + let targets: Vec = match resp.json().await { + Ok(t) => t, + Err(_) => continue, + }; + for target in &targets { + if target["type"].as_str() == Some("page") { + if let Some(ws) = target["webSocketDebuggerUrl"].as_str() { + return Ok(ws.to_string()); + } + } + } + } + Err("No page target found in Chromium".to_string()) + } + + /// Execute a browser command via CDP. + async fn execute(&mut self, cmd: BrowserCommand) -> BrowserResponse { + self.last_active = Instant::now(); + match cmd { + BrowserCommand::Navigate { url } => self.cmd_navigate(&url).await, + BrowserCommand::Click { selector } => self.cmd_click(&selector).await, + BrowserCommand::Type { selector, text } => self.cmd_type(&selector, &text).await, + BrowserCommand::Screenshot => self.cmd_screenshot().await, + BrowserCommand::ReadPage => self.cmd_read_page().await, + BrowserCommand::Close => BrowserResponse::ok(serde_json::json!({"closed": true})), + BrowserCommand::Scroll { direction, amount } => { + self.cmd_scroll(&direction, amount).await + } + BrowserCommand::Wait { + selector, + timeout_ms, + } => self.cmd_wait(&selector, timeout_ms).await, + BrowserCommand::RunJs { expression } => self.cmd_run_js(&expression).await, + BrowserCommand::Back => self.cmd_back().await, + } + } + + // ── Command implementations ──────────────────────────────────────── + + async fn cmd_navigate(&self, url: &str) -> BrowserResponse { + let result = self + .cdp + .send("Page.navigate", serde_json::json!({ "url": url })) + .await; + + if let Err(e) = result { + return BrowserResponse::err(format!("Navigate failed: {e}")); + } + + // Wait for page load + self.wait_for_load().await; + + match self.page_info().await { + Ok(info) => BrowserResponse::ok(info), + Err(e) => BrowserResponse::err(format!("Navigate succeeded but page info failed: {e}")), + } + } + + async fn cmd_click(&self, selector: &str) -> BrowserResponse { + let sel_json = serde_json::to_string(selector).unwrap_or_default(); + let js = format!( + r#"(() => {{ + let sel = {sel_json}; + let el = document.querySelector(sel); + if (!el) {{ + const all = document.querySelectorAll('a, button, [role="button"], input[type="submit"], [onclick]'); + const lower = sel.toLowerCase(); + for (const e of all) {{ + if (e.textContent.trim().toLowerCase().includes(lower)) {{ el = e; break; }} + }} + }} + if (!el) return JSON.stringify({{success: false, error: 'Element not found: ' + sel}}); + el.scrollIntoView({{block: 'center'}}); + el.click(); + return JSON.stringify({{success: true, tag: el.tagName, text: el.textContent.substring(0, 100).trim()}}); +}})()"# + ); + + match self.cdp.run_js(&js).await { + Ok(val) => { + let parsed: serde_json::Value = val + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(val); + if parsed["success"].as_bool() == Some(false) { + return BrowserResponse::err( + parsed["error"].as_str().unwrap_or("Click failed").to_string(), + ); + } + // Wait briefly for any navigation triggered by click + tokio::time::sleep(Duration::from_millis(500)).await; + self.wait_for_load().await; + match self.page_info().await { + Ok(info) => BrowserResponse::ok(info), + Err(_) => BrowserResponse::ok(parsed), + } + } + Err(e) => BrowserResponse::err(format!("Click failed: {e}")), + } + } + + async fn cmd_type(&self, selector: &str, text: &str) -> BrowserResponse { + let sel_json = serde_json::to_string(selector).unwrap_or_default(); + let text_json = serde_json::to_string(text).unwrap_or_default(); + let js = format!( + r#"(() => {{ + let sel = {sel_json}; + let txt = {text_json}; + let el = document.querySelector(sel); + if (!el) return JSON.stringify({{success: false, error: 'Input not found: ' + sel}}); + el.focus(); + el.value = txt; + el.dispatchEvent(new Event('input', {{bubbles: true}})); + el.dispatchEvent(new Event('change', {{bubbles: true}})); + return JSON.stringify({{success: true, selector: sel, typed: txt.length + ' chars'}}); +}})()"# + ); + + match self.cdp.run_js(&js).await { + Ok(val) => { + let parsed: serde_json::Value = val + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(val); + if parsed["success"].as_bool() == Some(false) { + BrowserResponse::err(parsed["error"].as_str().unwrap_or("Type failed")) + } else { + BrowserResponse::ok(parsed) + } + } + Err(e) => BrowserResponse::err(format!("Type failed: {e}")), + } + } + + async fn cmd_screenshot(&self) -> BrowserResponse { + match self + .cdp + .send( + "Page.captureScreenshot", + serde_json::json!({ "format": "png" }), + ) + .await + { + Ok(result) => { + let b64 = result["data"].as_str().unwrap_or(""); + let url = self + .cdp + .run_js("location.href") + .await + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + BrowserResponse::ok( + serde_json::json!({"image_base64": b64, "url": url, "format": "png"}), + ) + } + Err(e) => BrowserResponse::err(format!("Screenshot failed: {e}")), + } + } + + async fn cmd_read_page(&self) -> BrowserResponse { + match self.cdp.run_js(EXTRACT_CONTENT_JS).await { + Ok(val) => { + let parsed: serde_json::Value = val + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(val); + BrowserResponse::ok(parsed) + } + Err(e) => BrowserResponse::err(format!("ReadPage failed: {e}")), + } + } + + async fn cmd_scroll(&self, direction: &str, amount: i32) -> BrowserResponse { + let (dx, dy) = match direction { + "up" => (0, -amount), + "down" => (0, amount), + "left" => (-amount, 0), + "right" => (amount, 0), + _ => (0, amount), + }; + let js = format!("window.scrollBy({dx}, {dy}); JSON.stringify({{scrollX: window.scrollX, scrollY: window.scrollY}})"); + match self.cdp.run_js(&js).await { + Ok(val) => { + let parsed: serde_json::Value = val + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(val); + BrowserResponse::ok(parsed) + } + Err(e) => BrowserResponse::err(format!("Scroll failed: {e}")), + } + } + + async fn cmd_wait(&self, selector: &str, timeout_ms: u64) -> BrowserResponse { + let sel_json = serde_json::to_string(selector).unwrap_or_default(); + let max_ms = timeout_ms.min(30_000); + let polls = (max_ms / PAGE_LOAD_POLL_INTERVAL_MS).max(1); + + for _ in 0..polls { + let js = format!("document.querySelector({sel_json}) ? 'found' : null"); + if let Ok(val) = self.cdp.run_js(&js).await { + if val.as_str() == Some("found") { + return BrowserResponse::ok( + serde_json::json!({"found": true, "selector": selector}), + ); + } + } + tokio::time::sleep(Duration::from_millis(PAGE_LOAD_POLL_INTERVAL_MS)).await; + } + + BrowserResponse::err(format!( + "Timed out waiting for selector: {selector} ({max_ms}ms)" + )) + } + + async fn cmd_run_js(&self, expression: &str) -> BrowserResponse { + match self.cdp.run_js(expression).await { + Ok(val) => BrowserResponse::ok(serde_json::json!({"result": val})), + Err(e) => BrowserResponse::err(format!("JS execution failed: {e}")), + } + } + + async fn cmd_back(&self) -> BrowserResponse { + match self.cdp.run_js("history.back(); 'ok'").await { + Ok(_) => { + tokio::time::sleep(Duration::from_millis(500)).await; + self.wait_for_load().await; + match self.page_info().await { + Ok(info) => BrowserResponse::ok(info), + Err(e) => { + BrowserResponse::err(format!("Back succeeded but page info failed: {e}")) + } + } + } + Err(e) => BrowserResponse::err(format!("Back failed: {e}")), + } + } + + // ── Helpers ──────────────────────────────────────────────────────── + + /// Poll until document.readyState is 'complete' or 'interactive'. + async fn wait_for_load(&self) { + for _ in 0..PAGE_LOAD_MAX_POLLS { + if let Ok(val) = self.cdp.run_js("document.readyState").await { + let state = val.as_str().unwrap_or(""); + if state == "complete" || state == "interactive" { + return; + } + } + tokio::time::sleep(Duration::from_millis(PAGE_LOAD_POLL_INTERVAL_MS)).await; + } + } + + /// Get current page title, URL, and readable content. + async fn page_info(&self) -> Result { + let info = self + .cdp + .run_js("JSON.stringify({title: document.title, url: location.href})") + .await?; + let parsed: serde_json::Value = info + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(info); + + let content_val = self.cdp.run_js(EXTRACT_CONTENT_JS).await.unwrap_or_default(); + let content_obj: serde_json::Value = content_val + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(content_val); + let content_text = content_obj["content"].as_str().unwrap_or(""); + + Ok(serde_json::json!({ + "title": parsed["title"], + "url": parsed["url"], + "content": content_text, + })) } } impl Drop for BrowserSession { fn drop(&mut self) { - self.kill(); + let _ = self.process.start_kill(); } } -// ── Manager ───────────────────────────────────────────────────────────────── +// ── Chromium discovery ───────────────────────────────────────────────────── + +/// Find a Chromium-based browser binary on this system. +fn find_chromium(config: &BrowserConfig) -> Result { + // 1. User-configured path + if let Some(ref path) = config.chromium_path { + if !path.is_empty() { + let p = PathBuf::from(path); + if p.exists() { + return Ok(p); + } + return Err(format!("Configured chromium_path not found: {path}")); + } + } + + // 2. CHROME_PATH env var + if let Ok(path) = std::env::var("CHROME_PATH") { + let p = PathBuf::from(&path); + if p.exists() { + return Ok(p); + } + } + + // 3. Platform-specific search + let candidates = chromium_candidates(); + for candidate in &candidates { + let p = PathBuf::from(candidate); + if p.exists() { + return Ok(p); + } + } + + // 4. Try PATH lookup + for name in &[ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", + ] { + if let Ok(output) = std::process::Command::new("which").arg(name).output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + } + // Windows: use where.exe + #[cfg(windows)] + if let Ok(output) = std::process::Command::new("where.exe").arg(name).output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + } + } + + Err( + "Chromium/Chrome not found. Install Chrome or set CHROME_PATH. \ + Checked: Chrome, Chromium, Edge, Brave in standard locations." + .to_string(), + ) +} + +/// Platform-specific candidate paths for Chromium-based browsers. +fn chromium_candidates() -> Vec { + let mut paths = Vec::new(); + + #[cfg(windows)] + { + let program_files = std::env::var("ProgramFiles").unwrap_or_default(); + let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_default(); + let local_app = std::env::var("LOCALAPPDATA").unwrap_or_default(); + + for pf in &[&program_files, &program_files_x86] { + if pf.is_empty() { + continue; + } + paths.push(format!("{pf}\\Google\\Chrome\\Application\\chrome.exe")); + paths.push(format!("{pf}\\Microsoft\\Edge\\Application\\msedge.exe")); + paths.push(format!( + "{pf}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" + )); + } + if !local_app.is_empty() { + paths.push(format!( + "{local_app}\\Google\\Chrome\\Application\\chrome.exe" + )); + paths.push(format!( + "{local_app}\\Microsoft\\Edge\\Application\\msedge.exe" + )); + } + } + + #[cfg(target_os = "macos")] + { + paths.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".into()); + paths.push("/Applications/Chromium.app/Contents/MacOS/Chromium".into()); + paths.push("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge".into()); + paths.push("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser".into()); + } + + #[cfg(target_os = "linux")] + { + paths.push("/usr/bin/google-chrome".into()); + paths.push("/usr/bin/google-chrome-stable".into()); + paths.push("/usr/bin/chromium".into()); + paths.push("/usr/bin/chromium-browser".into()); + paths.push("/snap/bin/chromium".into()); + paths.push("/usr/bin/microsoft-edge".into()); + paths.push("/usr/bin/brave-browser".into()); + } + + paths +} + +// ── Browser manager ──────────────────────────────────────────────────────── /// Manages browser sessions for all agents. pub struct BrowserManager { - sessions: DashMap>, + sessions: DashMap>>, config: BrowserConfig, - bridge_path: OnceLock, } impl BrowserManager { @@ -111,196 +791,37 @@ impl BrowserManager { Self { sessions: DashMap::new(), config, - bridge_path: OnceLock::new(), } } - /// Write the embedded Python bridge script to a temp file (once). - fn ensure_bridge_script(&self) -> Result<&PathBuf, String> { - if let Some(path) = self.bridge_path.get() { - return Ok(path); - } - let dir = std::env::temp_dir().join("openfang"); - std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create temp dir: {e}"))?; - let path = dir.join("browser_bridge.py"); - std::fs::write(&path, BRIDGE_SCRIPT) - .map_err(|e| format!("Failed to write bridge script: {e}"))?; - debug!(path = %path.display(), "Wrote browser bridge script"); - // Race-safe: if another thread set it first, we just use theirs - let _ = self.bridge_path.set(path); - Ok(self.bridge_path.get().unwrap()) - } - - /// Get or create a browser session for the given agent. - /// This does synchronous subprocess spawn + I/O, so it must be called from - /// within `block_in_place` (see `send_command`). - fn get_or_create_sync(&self, agent_id: &str) -> Result<(), String> { - if self.sessions.contains_key(agent_id) { - return Ok(()); - } - - // Enforce session limit - if self.sessions.len() >= self.config.max_sessions { - return Err(format!( - "Maximum browser sessions reached ({}). Close an existing session first.", - self.config.max_sessions - )); - } - - let bridge_path = self.ensure_bridge_script()?; - - let mut cmd = std::process::Command::new(&self.config.python_path); - cmd.arg(bridge_path.to_string_lossy().as_ref()); - if self.config.headless { - cmd.arg("--headless"); - } else { - cmd.arg("--no-headless"); - } - cmd.arg("--width") - .arg(self.config.viewport_width.to_string()); - cmd.arg("--height") - .arg(self.config.viewport_height.to_string()); - cmd.arg("--timeout") - .arg(self.config.timeout_secs.to_string()); - - cmd.stdin(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::null()); - - // SECURITY: Isolate environment — clear everything, pass through only essentials - cmd.env_clear(); - #[cfg(windows)] - { - if let Ok(v) = std::env::var("SYSTEMROOT") { - cmd.env("SYSTEMROOT", v); - } - if let Ok(v) = std::env::var("PATH") { - cmd.env("PATH", v); - } - if let Ok(v) = std::env::var("TEMP") { - cmd.env("TEMP", v); - } - if let Ok(v) = std::env::var("TMP") { - cmd.env("TMP", v); - } - // Playwright needs these to find installed browsers - if let Ok(v) = std::env::var("USERPROFILE") { - cmd.env("USERPROFILE", v); - } - if let Ok(v) = std::env::var("APPDATA") { - cmd.env("APPDATA", v); - } - if let Ok(v) = std::env::var("LOCALAPPDATA") { - cmd.env("LOCALAPPDATA", v); - } - cmd.env("PYTHONIOENCODING", "utf-8"); - } - #[cfg(not(windows))] - { - if let Ok(v) = std::env::var("PATH") { - cmd.env("PATH", v); - } - if let Ok(v) = std::env::var("HOME") { - cmd.env("HOME", v); - } - if let Ok(v) = std::env::var("TMPDIR") { - cmd.env("TMPDIR", v); - } - if let Ok(v) = std::env::var("XDG_CACHE_HOME") { - cmd.env("XDG_CACHE_HOME", v); - } - } - - let mut child = cmd.spawn().map_err(|e| { - format!( - "Failed to spawn browser bridge: {e}. Ensure Python and playwright are installed." - ) - })?; - - let stdin = child.stdin.take().ok_or("Failed to capture bridge stdin")?; - let stdout = child - .stdout - .take() - .ok_or("Failed to capture bridge stdout")?; - let mut reader = BufReader::new(stdout); - - // Wait for the "ready" response - let mut ready_line = String::new(); - reader - .read_line(&mut ready_line) - .map_err(|e| format!("Bridge failed to start: {e}"))?; - - if ready_line.trim().is_empty() { - let _ = child.kill(); - return Err("Browser bridge process exited without sending ready signal. Check Python/Playwright installation.".to_string()); - } - - let ready: BrowserResponse = serde_json::from_str(ready_line.trim()) - .map_err(|e| format!("Bridge startup failed: {e}. Output: {ready_line}"))?; - - if !ready.success { - let err = ready.error.unwrap_or_else(|| "Unknown error".to_string()); - let _ = child.kill(); - return Err(format!("Browser bridge failed to start: {err}")); - } - - info!(agent_id, "Browser session created"); - - let session = BrowserSession { - child, - stdin, - stdout: reader, - last_active: Instant::now(), - }; - - self.sessions - .insert(agent_id.to_string(), Mutex::new(session)); - Ok(()) - } - - /// Check whether an agent has an active browser session (without creating one). + /// Check whether an agent has an active browser session. pub fn has_session(&self, agent_id: &str) -> bool { self.sessions.contains_key(agent_id) } - /// Send a command to an agent's browser session. + /// Send a command to an agent's browser session (creating one if needed). pub async fn send_command( &self, agent_id: &str, cmd: BrowserCommand, ) -> Result { - // Session creation involves sync subprocess spawn + I/O - tokio::task::block_in_place(|| self.get_or_create_sync(agent_id))?; + let session = self.get_or_create(agent_id).await?; + let mut guard = session.lock().await; + let resp = guard.execute(cmd).await; - let session_ref = self - .sessions - .get(agent_id) - .ok_or_else(|| "Session disappeared".to_string())?; - - let session_mutex = session_ref.value(); - let mut session = session_mutex.lock().await; - - // Run synchronous I/O in a blocking context - let response = tokio::task::block_in_place(|| session.send(&cmd))?; - - if !response.success { - let err = response - .error - .clone() - .unwrap_or_else(|| "Unknown error".to_string()); - warn!(agent_id, error = %err, "Browser command failed"); + if !resp.success { + if let Some(ref err) = resp.error { + warn!(agent_id, error = %err, "Browser command failed"); + } } - Ok(response) + Ok(resp) } /// Close an agent's browser session. pub async fn close_session(&self, agent_id: &str) { - if let Some((_, session_mutex)) = self.sessions.remove(agent_id) { - let mut session = session_mutex.lock().await; - // Try graceful close - let _ = session.send(&BrowserCommand::Close); - session.kill(); + if let Some((_, session)) = self.sessions.remove(agent_id) { + drop(session); info!(agent_id, "Browser session closed"); } } @@ -309,19 +830,37 @@ impl BrowserManager { pub async fn cleanup_agent(&self, agent_id: &str) { self.close_session(agent_id).await; } + + /// Get existing session or create a new one. + async fn get_or_create(&self, agent_id: &str) -> Result>, String> { + if let Some(entry) = self.sessions.get(agent_id) { + return Ok(Arc::clone(entry.value())); + } + + if self.sessions.len() >= self.config.max_sessions { + return Err(format!( + "Maximum browser sessions reached ({}). Close an existing session first.", + self.config.max_sessions + )); + } + + let session = BrowserSession::launch(&self.config).await?; + let arc = Arc::new(Mutex::new(session)); + self.sessions.insert(agent_id.to_string(), Arc::clone(&arc)); + info!(agent_id, "Browser session created (native CDP)"); + Ok(arc) + } } -// ── Tool handler functions ────────────────────────────────────────────────── +// ── Tool handler functions ───────────────────────────────────────────────── -/// browser_navigate — Navigate to a URL. SSRF-checked in Rust before delegating. +/// browser_navigate: Navigate to a URL. SSRF-checked before sending. pub async fn tool_browser_navigate( input: &serde_json::Value, mgr: &BrowserManager, agent_id: &str, ) -> Result { let url = input["url"].as_str().ok_or("Missing 'url' parameter")?; - - // SECURITY: SSRF check in Rust before sending to Python crate::web_fetch::check_ssrf(url)?; let resp = mgr @@ -332,7 +871,6 @@ pub async fn tool_browser_navigate( }, ) .await?; - if !resp.success { return Err(resp.error.unwrap_or_else(|| "Navigate failed".to_string())); } @@ -341,8 +879,6 @@ pub async fn tool_browser_navigate( let title = data["title"].as_str().unwrap_or("(no title)"); let page_url = data["url"].as_str().unwrap_or(url); let content = data["content"].as_str().unwrap_or(""); - - // Wrap with external content markers let wrapped = crate::web_content::wrap_external_content(page_url, content); Ok(format!( @@ -350,7 +886,7 @@ pub async fn tool_browser_navigate( )) } -/// browser_click — Click an element by CSS selector or text. +/// browser_click: Click an element by CSS selector or visible text. pub async fn tool_browser_click( input: &serde_json::Value, mgr: &BrowserManager, @@ -368,7 +904,6 @@ pub async fn tool_browser_click( }, ) .await?; - if !resp.success { return Err(resp.error.unwrap_or_else(|| "Click failed".to_string())); } @@ -376,11 +911,10 @@ pub async fn tool_browser_click( let data = resp.data.unwrap_or_default(); let title = data["title"].as_str().unwrap_or("(no title)"); let url = data["url"].as_str().unwrap_or(""); - Ok(format!("Clicked: {selector}\nPage: {title}\nURL: {url}")) } -/// browser_type — Type text into an input field. +/// browser_type: Type text into an input field. pub async fn tool_browser_type( input: &serde_json::Value, mgr: &BrowserManager, @@ -400,15 +934,13 @@ pub async fn tool_browser_type( }, ) .await?; - if !resp.success { return Err(resp.error.unwrap_or_else(|| "Type failed".to_string())); } - Ok(format!("Typed into {selector}: {text}")) } -/// browser_screenshot — Take a screenshot of the current page. +/// browser_screenshot: Take a screenshot of the current page. pub async fn tool_browser_screenshot( _input: &serde_json::Value, mgr: &BrowserManager, @@ -417,7 +949,6 @@ pub async fn tool_browser_screenshot( let resp = mgr .send_command(agent_id, BrowserCommand::Screenshot) .await?; - if !resp.success { return Err(resp .error @@ -428,7 +959,6 @@ pub async fn tool_browser_screenshot( let b64 = data["image_base64"].as_str().unwrap_or(""); let url = data["url"].as_str().unwrap_or(""); - // Save screenshot to uploads temp dir so it's accessible via /api/uploads/ let mut image_urls: Vec = Vec::new(); if !b64.is_empty() { use base64::Engine; @@ -443,23 +973,23 @@ pub async fn tool_browser_screenshot( } } - let result = serde_json::json!({ + Ok(serde_json::json!({ "screenshot": true, "url": url, "image_urls": image_urls, - }); - - Ok(result.to_string()) + }) + .to_string()) } -/// browser_read_page — Read the current page content as markdown. +/// browser_read_page: Read current page content as markdown. pub async fn tool_browser_read_page( _input: &serde_json::Value, mgr: &BrowserManager, agent_id: &str, ) -> Result { - let resp = mgr.send_command(agent_id, BrowserCommand::ReadPage).await?; - + let resp = mgr + .send_command(agent_id, BrowserCommand::ReadPage) + .await?; if !resp.success { return Err(resp.error.unwrap_or_else(|| "ReadPage failed".to_string())); } @@ -468,13 +998,12 @@ pub async fn tool_browser_read_page( let title = data["title"].as_str().unwrap_or("(no title)"); let url = data["url"].as_str().unwrap_or(""); let content = data["content"].as_str().unwrap_or(""); - let wrapped = crate::web_content::wrap_external_content(url, content); Ok(format!("Page: {title}\nURL: {url}\n\n{wrapped}")) } -/// browser_close — Close the browser session for this agent. +/// browser_close: Close the browser session. pub async fn tool_browser_close( _input: &serde_json::Value, mgr: &BrowserManager, @@ -484,7 +1013,148 @@ pub async fn tool_browser_close( Ok("Browser session closed.".to_string()) } -// ── Tests ─────────────────────────────────────────────────────────────────── +/// browser_scroll: Scroll the page in a direction. +pub async fn tool_browser_scroll( + input: &serde_json::Value, + mgr: &BrowserManager, + agent_id: &str, +) -> Result { + let direction = input["direction"].as_str().unwrap_or("down").to_string(); + let amount = input["amount"].as_i64().unwrap_or(600) as i32; + + let resp = mgr + .send_command(agent_id, BrowserCommand::Scroll { direction, amount }) + .await?; + if !resp.success { + return Err(resp.error.unwrap_or_else(|| "Scroll failed".to_string())); + } + let data = resp.data.unwrap_or_default(); + Ok(format!( + "Scrolled. Position: scrollX={}, scrollY={}", + data["scrollX"], data["scrollY"] + )) +} + +/// browser_wait: Wait for a CSS selector to appear on the page. +pub async fn tool_browser_wait( + input: &serde_json::Value, + mgr: &BrowserManager, + agent_id: &str, +) -> Result { + let selector = input["selector"] + .as_str() + .ok_or("Missing 'selector' parameter")?; + let timeout_ms = input["timeout_ms"].as_u64().unwrap_or(5000); + + let resp = mgr + .send_command( + agent_id, + BrowserCommand::Wait { + selector: selector.to_string(), + timeout_ms, + }, + ) + .await?; + if !resp.success { + return Err(resp.error.unwrap_or_else(|| "Wait timed out".to_string())); + } + Ok(format!("Element found: {selector}")) +} + +/// browser_run_js: Run JavaScript on the current page. +pub async fn tool_browser_run_js( + input: &serde_json::Value, + mgr: &BrowserManager, + agent_id: &str, +) -> Result { + let expression = input["expression"] + .as_str() + .ok_or("Missing 'expression' parameter")?; + + let resp = mgr + .send_command( + agent_id, + BrowserCommand::RunJs { + expression: expression.to_string(), + }, + ) + .await?; + if !resp.success { + return Err(resp + .error + .unwrap_or_else(|| "JS execution failed".to_string())); + } + let data = resp.data.unwrap_or_default(); + Ok(serde_json::to_string_pretty(&data["result"]).unwrap_or_else(|_| "null".to_string())) +} + +/// browser_back: Go back in browser history. +pub async fn tool_browser_back( + _input: &serde_json::Value, + mgr: &BrowserManager, + agent_id: &str, +) -> Result { + let resp = mgr.send_command(agent_id, BrowserCommand::Back).await?; + if !resp.success { + return Err(resp.error.unwrap_or_else(|| "Back failed".to_string())); + } + let data = resp.data.unwrap_or_default(); + let title = data["title"].as_str().unwrap_or("(no title)"); + let url = data["url"].as_str().unwrap_or(""); + Ok(format!("Went back.\nPage: {title}\nURL: {url}")) +} + +// ── Embedded JavaScript ──────────────────────────────────────────────────── + +/// JavaScript to extract readable page content as markdown. +const EXTRACT_CONTENT_JS: &str = r#"(() => { + const title = document.title || ''; + const url = location.href || ''; + const body = document.body; + if (!body) return JSON.stringify({title, url, content: ''}); + + const clone = body.cloneNode(true); + const remove = ['script','style','nav','footer','header','aside','iframe','noscript','svg','canvas']; + remove.forEach(tag => clone.querySelectorAll(tag).forEach(el => el.remove())); + + let root = clone.querySelector('main, article, [role="main"], .content, #content'); + if (!root) root = clone; + + const lines = []; + function walk(node) { + if (node.nodeType === 3) { + const t = node.textContent.trim(); + if (t) lines.push(t); + return; + } + if (node.nodeType !== 1) return; + const tag = node.tagName.toLowerCase(); + if (['h1','h2','h3','h4','h5','h6'].includes(tag)) { + const level = '#'.repeat(parseInt(tag[1])); + lines.push('\n' + level + ' ' + node.textContent.trim()); + return; + } + if (tag === 'a' && node.href && node.textContent.trim()) { + lines.push('[' + node.textContent.trim() + '](' + node.href + ')'); + return; + } + if (tag === 'li') { + lines.push('- ' + node.textContent.trim()); + return; + } + if (tag === 'br') { lines.push(''); return; } + if (['p','div','section','tr'].includes(tag)) lines.push(''); + for (const child of node.childNodes) walk(child); + if (['p','div','section','tr'].includes(tag)) lines.push(''); + } + walk(root); + + let content = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); + if (content.length > 50000) content = content.substring(0, 50000) + '\n... (truncated)'; + return JSON.stringify({title, url, content}); +})()"#; + +// ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { @@ -499,6 +1169,7 @@ mod tests { assert_eq!(config.timeout_secs, 30); assert_eq!(config.idle_timeout_secs, 300); assert_eq!(config.max_sessions, 5); + assert!(config.chromium_path.is_none()); } #[test] @@ -553,6 +1224,44 @@ mod tests { assert!(json.contains("\"action\":\"Close\"")); } + #[test] + fn test_browser_command_serialize_scroll() { + let cmd = BrowserCommand::Scroll { + direction: "down".to_string(), + amount: 500, + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert!(json.contains("\"action\":\"Scroll\"")); + assert!(json.contains("\"amount\":500")); + } + + #[test] + fn test_browser_command_serialize_run_js() { + let cmd = BrowserCommand::RunJs { + expression: "document.title".to_string(), + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert!(json.contains("\"action\":\"RunJs\"")); + } + + #[test] + fn test_browser_command_serialize_back() { + let cmd = BrowserCommand::Back; + let json = serde_json::to_string(&cmd).unwrap(); + assert!(json.contains("\"action\":\"Back\"")); + } + + #[test] + fn test_browser_command_serialize_wait() { + let cmd = BrowserCommand::Wait { + selector: "#loaded".to_string(), + timeout_ms: 3000, + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert!(json.contains("\"action\":\"Wait\"")); + assert!(json.contains("\"timeout_ms\":3000")); + } + #[test] fn test_browser_response_deserialize() { let json = @@ -580,4 +1289,21 @@ mod tests { let mgr = BrowserManager::new(config); assert!(mgr.sessions.is_empty()); } + + #[test] + fn test_chromium_candidates_not_empty() { + let paths = chromium_candidates(); + assert!(!paths.is_empty(), "Should have platform-specific candidates"); + } + + #[test] + fn test_response_helpers() { + let ok = BrowserResponse::ok(serde_json::json!({"a": 1})); + assert!(ok.success); + assert!(ok.error.is_none()); + + let err = BrowserResponse::err("bad"); + assert!(!err.success); + assert_eq!(err.error.unwrap(), "bad"); + } } diff --git a/crates/openfang-runtime/src/browser_bridge.py b/crates/openfang-runtime/src/browser_bridge.py deleted file mode 100644 index d95ee607..00000000 --- a/crates/openfang-runtime/src/browser_bridge.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -"""OpenFang Browser Bridge — Playwright automation over JSON-line stdio protocol. - -Reads JSON commands from stdin (one per line), executes browser actions via -Playwright, and writes JSON responses to stdout (one per line). - -Usage: - python browser_bridge.py [--headless] [--width 1280] [--height 720] [--timeout 30] -""" - -import argparse -import base64 -import json -import re -import sys -import traceback - -def main(): - parser = argparse.ArgumentParser(description="OpenFang Browser Bridge") - parser.add_argument("--headless", action="store_true", default=True) - parser.add_argument("--no-headless", dest="headless", action="store_false") - parser.add_argument("--width", type=int, default=1280) - parser.add_argument("--height", type=int, default=720) - parser.add_argument("--timeout", type=int, default=30) - args = parser.parse_args() - - timeout_ms = args.timeout * 1000 - - try: - from playwright.sync_api import sync_playwright - except ImportError: - respond({"success": False, "error": "playwright not installed. Run: pip install playwright && playwright install chromium"}) - return - - pw = sync_playwright().start() - browser = pw.chromium.launch(headless=args.headless) - context = browser.new_context( - viewport={"width": args.width, "height": args.height}, - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - ) - page = context.new_page() - page.set_default_timeout(timeout_ms) - page.set_default_navigation_timeout(timeout_ms) - - # Signal ready - respond({"success": True, "data": {"status": "ready"}}) - - for line in sys.stdin: - line = line.strip() - if not line: - continue - action = None - try: - cmd = json.loads(line) - action = cmd.get("action", "") - result = handle_command(page, context, action, cmd, timeout_ms) - respond(result) - except Exception as e: - respond({"success": False, "error": f"{type(e).__name__}: {e}"}) - - if action == "Close": - break - - # Cleanup - try: - context.close() - browser.close() - pw.stop() - except Exception: - pass - - -def handle_command(page, context, action, cmd, timeout_ms): - if action == "Navigate": - url = cmd.get("url", "") - if not url: - return {"success": False, "error": "Missing 'url' parameter"} - page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms) - title = page.title() - content = extract_readable(page) - return {"success": True, "data": {"title": title, "url": page.url, "content": content}} - - elif action == "Click": - selector = cmd.get("selector", "") - if not selector: - return {"success": False, "error": "Missing 'selector' parameter"} - # Try CSS selector first, then text content - try: - page.click(selector, timeout=timeout_ms) - except Exception: - # Fallback: try as text - page.get_by_text(selector, exact=False).first.click(timeout=timeout_ms) - page.wait_for_load_state("domcontentloaded", timeout=timeout_ms) - title = page.title() - return {"success": True, "data": {"clicked": selector, "title": title, "url": page.url}} - - elif action == "Type": - selector = cmd.get("selector", "") - text = cmd.get("text", "") - if not selector: - return {"success": False, "error": "Missing 'selector' parameter"} - if not text: - return {"success": False, "error": "Missing 'text' parameter"} - page.fill(selector, text, timeout=timeout_ms) - return {"success": True, "data": {"typed": text, "selector": selector}} - - elif action == "Screenshot": - screenshot_bytes = page.screenshot(full_page=False) - b64 = base64.b64encode(screenshot_bytes).decode("utf-8") - return {"success": True, "data": {"image_base64": b64, "format": "png", "url": page.url}} - - elif action == "ReadPage": - title = page.title() - content = extract_readable(page) - return {"success": True, "data": {"title": title, "url": page.url, "content": content}} - - elif action == "Close": - return {"success": True, "data": {"status": "closed"}} - - else: - return {"success": False, "error": f"Unknown action: {action}"} - - -def extract_readable(page): - """Extract readable text content from the page, stripping nav/footer/script noise.""" - try: - # Remove script, style, nav, footer, header elements - content = page.evaluate("""() => { - const clone = document.body.cloneNode(true); - const remove = ['script', 'style', 'nav', 'footer', 'header', 'aside', - 'iframe', 'noscript', 'svg', 'canvas']; - remove.forEach(tag => { - clone.querySelectorAll(tag).forEach(el => el.remove()); - }); - - // Try to find main content area - const main = clone.querySelector('main, article, [role="main"], .content, #content'); - const source = main || clone; - - // Extract text with basic structure - const lines = []; - const walk = (node) => { - if (node.nodeType === 3) { - const text = node.textContent.trim(); - if (text) lines.push(text); - } else if (node.nodeType === 1) { - const tag = node.tagName.toLowerCase(); - if (['h1','h2','h3','h4','h5','h6'].includes(tag)) { - lines.push('\\n## ' + node.textContent.trim()); - } else if (tag === 'li') { - lines.push('- ' + node.textContent.trim()); - } else if (tag === 'a' && node.href) { - lines.push('[' + node.textContent.trim() + '](' + node.href + ')'); - } else if (['p', 'div', 'section', 'td', 'th'].includes(tag)) { - for (const child of node.childNodes) walk(child); - lines.push(''); - } else { - for (const child of node.childNodes) walk(child); - } - } - }; - walk(source); - return lines.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim(); - }""") - # Truncate to prevent huge payloads - max_chars = 50000 - if len(content) > max_chars: - content = content[:max_chars] + f"\n\n[Truncated — {len(content)} total chars]" - return content - except Exception: - # Fallback: plain innerText - try: - text = page.inner_text("body") - if len(text) > 50000: - text = text[:50000] + f"\n\n[Truncated — {len(text)} total chars]" - return text - except Exception: - return "(could not extract page content)" - - -def respond(data): - """Write a JSON response line to stdout.""" - sys.stdout.write(json.dumps(data) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 32426448..ddd68fa1 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -115,6 +115,19 @@ impl ModelCatalog { .collect() } + /// Return the default model ID for a provider (first model in catalog order). + pub fn default_model_for_provider(&self, provider: &str) -> Option { + // Check aliases first — e.g. "minimax" alias resolves to "MiniMax-M2.5" + if let Some(model_id) = self.aliases.get(provider) { + return Some(model_id.clone()); + } + // Fall back to the first model registered for this provider + self.models + .iter() + .find(|m| m.provider == provider) + .map(|m| m.id.clone()) + } + /// List models that are available (from configured providers only). pub fn available_models(&self) -> Vec<&ModelCatalogEntry> { let configured: Vec<&str> = self diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index b3f84211..9bbaa135 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -213,7 +213,11 @@ pub async fn execute_tool( { return ToolResult { tool_use_id: tool_use_id.to_string(), - content: format!("Exec policy denied: {reason}"), + content: format!( + "shell_exec blocked: {reason}. Current exec_policy.mode = '{:?}'. \ + To allow shell commands, set exec_policy.mode = 'full' in the agent manifest or config.toml.", + policy.mode + ), is_error: true, }; } @@ -330,7 +334,7 @@ pub async fn execute_tool( crate::browser::tool_browser_navigate(input, mgr, aid).await } None => Err( - "Browser tools not available. Ensure Python and playwright are installed." + "Browser tools not available. Ensure Chrome/Chromium is installed." .to_string(), ), } @@ -340,35 +344,63 @@ pub async fn execute_tool( let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_click(input, mgr, aid).await } - None => Err("Browser tools not available.".to_string()), + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_type" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_type(input, mgr, aid).await } - None => Err("Browser tools not available.".to_string()), + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_screenshot" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_screenshot(input, mgr, aid).await } - None => Err("Browser tools not available.".to_string()), + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_read_page" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_read_page(input, mgr, aid).await } - None => Err("Browser tools not available.".to_string()), + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_close" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_close(input, mgr, aid).await } - None => Err("Browser tools not available.".to_string()), + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + }, + "browser_scroll" => match browser_ctx { + Some(mgr) => { + let aid = caller_agent_id.unwrap_or("default"); + crate::browser::tool_browser_scroll(input, mgr, aid).await + } + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + }, + "browser_wait" => match browser_ctx { + Some(mgr) => { + let aid = caller_agent_id.unwrap_or("default"); + crate::browser::tool_browser_wait(input, mgr, aid).await + } + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + }, + "browser_run_js" => match browser_ctx { + Some(mgr) => { + let aid = caller_agent_id.unwrap_or("default"); + crate::browser::tool_browser_run_js(input, mgr, aid).await + } + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + }, + "browser_back" => match browser_ctx { + Some(mgr) => { + let aid = caller_agent_id.unwrap_or("default"); + crate::browser::tool_browser_back(input, mgr, aid).await + } + None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, // Canvas / A2UI tool @@ -828,6 +860,48 @@ pub fn builtin_tool_definitions() -> Vec { "properties": {} }), }, + ToolDefinition { + name: "browser_scroll".to_string(), + description: "Scroll the browser page. Use this to see content below the fold or navigate long pages.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "direction": { "type": "string", "description": "Scroll direction: 'up', 'down', 'left', 'right' (default: 'down')" }, + "amount": { "type": "integer", "description": "Pixels to scroll (default: 600)" } + } + }), + }, + ToolDefinition { + name: "browser_wait".to_string(), + description: "Wait for a CSS selector to appear on the page. Useful for dynamic content that loads asynchronously.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "selector": { "type": "string", "description": "CSS selector to wait for" }, + "timeout_ms": { "type": "integer", "description": "Max wait time in milliseconds (default: 5000, max: 30000)" } + }, + "required": ["selector"] + }), + }, + ToolDefinition { + name: "browser_run_js".to_string(), + description: "Run JavaScript on the current browser page and return the result. For advanced interactions that other browser tools cannot handle.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "expression": { "type": "string", "description": "JavaScript expression to run in the page context" } + }, + "required": ["expression"] + }), + }, + ToolDefinition { + name: "browser_back".to_string(), + description: "Go back to the previous page in browser history.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {} + }), + }, // --- Media understanding tools --- ToolDefinition { name: "media_describe".to_string(), @@ -3018,6 +3092,10 @@ mod tests { assert!(names.contains(&"browser_screenshot")); assert!(names.contains(&"browser_read_page")); assert!(names.contains(&"browser_close")); + assert!(names.contains(&"browser_scroll")); + assert!(names.contains(&"browser_wait")); + assert!(names.contains(&"browser_run_js")); + assert!(names.contains(&"browser_back")); // 3 media/image generation tools assert!(names.contains(&"media_describe")); assert!(names.contains(&"media_transcribe")); diff --git a/crates/openfang-runtime/src/web_content.rs b/crates/openfang-runtime/src/web_content.rs index e07fe58a..d24c4198 100644 --- a/crates/openfang-runtime/src/web_content.rs +++ b/crates/openfang-runtime/src/web_content.rs @@ -6,6 +6,31 @@ use sha2::{Digest, Sha256}; +// --------------------------------------------------------------------------- +// ASCII case-insensitive find — byte offsets always valid on original string +// --------------------------------------------------------------------------- + +/// Find `needle` in `haystack` starting at byte offset `from`, comparing +/// ASCII characters case-insensitively. Since HTML tags are ASCII, this +/// avoids the byte-length mismatch caused by `str::to_lowercase()` on +/// multi-byte Unicode (e.g. `İ` 2 bytes → `i̇` 4 bytes). +fn find_ci(haystack: &str, needle: &str, from: usize) -> Option { + let h = haystack.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() || from + n.len() > h.len() { + return None; + } + 'outer: for i in from..=(h.len() - n.len()) { + for j in 0..n.len() { + if !h[i + j].eq_ignore_ascii_case(&n[j]) { + continue 'outer; + } + } + return Some(i); + } + None +} + // --------------------------------------------------------------------------- // External content markers // --------------------------------------------------------------------------- @@ -79,19 +104,17 @@ fn remove_non_content_blocks(html: &str) -> String { /// Remove all occurrences of a specific tag and its contents (case-insensitive). fn remove_tag_blocks(html: &str, tag: &str) -> String { let mut result = String::with_capacity(html.len()); - let lower = html.to_lowercase(); let open_tag = format!("<{}", tag); let close_tag = format!("", tag); let mut pos = 0; while pos < html.len() { - if let Some(start) = lower[pos..].find(&open_tag) { - let abs_start = pos + start; + if let Some(abs_start) = find_ci(html, &open_tag, pos) { result.push_str(&html[pos..abs_start]); // Find the matching close tag - if let Some(end) = lower[abs_start..].find(&close_tag) { - pos = abs_start + end + close_tag.len(); + if let Some(end) = find_ci(html, &close_tag, abs_start) { + pos = end + close_tag.len(); } else { // No close tag — remove to end of self-closing or skip the open tag if let Some(gt) = html[abs_start..].find('>') { @@ -110,16 +133,15 @@ fn remove_tag_blocks(html: &str, tag: &str) -> String { /// Extract the content from
,
, or (in priority order). fn extract_main_content(html: &str) -> String { - let lower = html.to_lowercase(); for tag in &["main", "article", "body"] { let open = format!("<{}", tag); let close = format!("", tag); - if let Some(start) = lower.find(&open) { + if let Some(start) = find_ci(html, &open, 0) { // Skip past the opening tag's > if let Some(gt) = html[start..].find('>') { let content_start = start + gt + 1; - if let Some(end) = lower[content_start..].find(&close) { - return html[content_start..content_start + end].to_string(); + if let Some(end) = find_ci(html, &close, content_start) { + return html[content_start..end].to_string(); } } } @@ -193,23 +215,21 @@ fn convert_inline_tag( md_close: &str, ) -> String { let mut result = String::with_capacity(html.len()); - let lower = html.to_lowercase(); let mut pos = 0; while pos < html.len() { - if let Some(start) = lower[pos..].find(open_prefix) { - let abs_start = pos + start; + if let Some(abs_start) = find_ci(html, open_prefix, pos) { result.push_str(&html[pos..abs_start]); // Find the end of the opening tag if let Some(gt) = html[abs_start..].find('>') { let content_start = abs_start + gt + 1; // Find the close tag - if let Some(end) = lower[content_start..].find(close) { + if let Some(end) = find_ci(html, close, content_start) { result.push_str(md_open); - result.push_str(&html[content_start..content_start + end]); + result.push_str(&html[content_start..end]); result.push_str(md_close); - pos = content_start + end + close.len(); + pos = end + close.len(); } else { // No close tag, just skip the open tag result.push_str(md_open); @@ -230,12 +250,10 @@ fn convert_inline_tag( /// Convert text to [text](url). fn convert_links(html: &str) -> String { let mut result = String::with_capacity(html.len()); - let lower = html.to_lowercase(); let mut pos = 0; while pos < html.len() { - if let Some(start) = lower[pos..].find(" String { if let Some(gt) = tag_content.find('>') { let text_start = abs_start + gt + 1; - if let Some(end) = lower[text_start..].find("") { - let link_text = strip_all_tags(&html[text_start..text_start + end]); + if let Some(end) = find_ci(html, "", text_start) { + let link_text = strip_all_tags(&html[text_start..end]); if let Some(url) = href { result.push_str(&format!("[{}]({})", link_text.trim(), url)); } else { result.push_str(link_text.trim()); } - pos = text_start + end + 4; // skip + pos = end + 4; // skip } else { pos = text_start; } @@ -269,9 +287,8 @@ fn convert_links(html: &str) -> String { /// Extract an attribute value from an HTML tag. fn extract_attribute(tag: &str, attr: &str) -> Option { - let lower = tag.to_lowercase(); let pattern = format!("{}=\"", attr); - if let Some(start) = lower.find(&pattern) { + if let Some(start) = find_ci(tag, &pattern, 0) { let val_start = start + pattern.len(); if let Some(end) = tag[val_start..].find('"') { return Some(tag[val_start..val_start + end].to_string()); @@ -279,7 +296,7 @@ fn extract_attribute(tag: &str, attr: &str) -> Option { } // Try single quotes let pattern_sq = format!("{}='", attr); - if let Some(start) = lower.find(&pattern_sq) { + if let Some(start) = find_ci(tag, &pattern_sq, 0) { let val_start = start + pattern_sq.len(); if let Some(end) = tag[val_start..].find('\'') { return Some(tag[val_start..val_start + end].to_string()); @@ -389,4 +406,41 @@ mod tests { assert!(result.contains("Keep")); assert!(result.contains("this")); } + + #[test] + fn test_find_ci_basic() { + assert_eq!(find_ci("Hello World", "hello", 0), Some(0)); + assert_eq!(find_ci("Hello World", "WORLD", 0), Some(6)); + assert_eq!(find_ci("Hello World", "xyz", 0), None); + assert_eq!(find_ci("Hello World", "world", 6), Some(6)); + assert_eq!(find_ci("Hello World", "hello", 1), None); + } + + #[test] + fn test_unicode_no_panic() { + // Turkish dotted I: İ is 2 bytes, but lowercase i̇ is 4 bytes. + // German sharp S: ẞ is 3 bytes, lowercase ß is 2 bytes. + // This used to panic because to_lowercase() changed byte lengths. + let html = "İstanbul ẞtraße bold text"; + let md = html_to_markdown(html); + assert!(md.contains("**bold**"), "Expected bold, got: {md}"); + assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}"); + } + + #[test] + fn test_unicode_in_script_removal() { + let html = "
Ünïcödé keep
"; + let result = remove_non_content_blocks(html); + assert!(!result.contains("İstanbul")); + assert!(result.contains("Ünïcödé")); + assert!(result.contains("keep")); + } + + #[test] + fn test_mixed_case_tags() { + let html = "

Title

Hello world.

"; + let md = html_to_markdown(html); + assert!(md.contains("# Title"), "Expected heading, got: {md}"); + assert!(md.contains("**world**"), "Expected bold, got: {md}"); + } } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index b4e2f762..66a2eb65 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -281,8 +281,8 @@ pub struct BrowserConfig { pub idle_timeout_secs: u64, /// Maximum concurrent browser sessions. pub max_sessions: usize, - /// Python executable path (e.g., "python3" on Unix, "python" on Windows). - pub python_path: String, + /// Path to Chromium/Chrome binary. Auto-detected if None. + pub chromium_path: Option, } impl Default for BrowserConfig { @@ -294,11 +294,7 @@ impl Default for BrowserConfig { timeout_secs: 30, idle_timeout_secs: 300, max_sessions: 5, - python_path: if cfg!(windows) { - "python".to_string() - } else { - "python3".to_string() - }, + chromium_path: None, } } } diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index 4e48a4d4..e477b366 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -34,7 +34,7 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com"; // ── Chinese providers ───────────────────────────────────────────── pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1"; -pub const MINIMAX_BASE_URL: &str = "https://api.minimax.chat/v1"; +pub const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1"; pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4"; pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4"; pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";