From 90f77a64cf4c8de6b010bfe6dbcb49b89e1c172e Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Tue, 30 Jun 2026 04:47:19 -0700 Subject: [PATCH] feat(browser): add Playwright backend (#4140) --- .github/workflows/pr-ci.yml | 18 +- Cargo.toml | 3 + .../panels/AutocompleteDebugPanel.tsx | 19 +- app/src/utils/tauriCommands/config.ts | 1 + src/openhuman/config/ops/ui.rs | 24 + src/openhuman/config/ops_tests.rs | 42 ++ src/openhuman/config/schema/tools/browser.rs | 2 +- src/openhuman/config/schemas/controllers.rs | 1 + src/openhuman/config/schemas/helpers.rs | 1 + src/openhuman/config/schemas/schema_defs.rs | 8 +- .../tools/impl/browser/action_parser.rs | 1 + src/openhuman/tools/impl/browser/browser.rs | 62 ++- .../tools/impl/browser/browser_tests.rs | 57 ++- src/openhuman/tools/impl/browser/mod.rs | 1 + .../tools/impl/browser/playwright_backend.rs | 407 +++++++++++++++++ .../tools/impl/browser/playwright_runner.mjs | 422 ++++++++++++++++++ src/openhuman/tools/impl/browser/types.rs | 6 +- ...io_list_tools_stack_overflow_regression.rs | 5 + tests/inference_agent_raw_coverage_e2e.rs | 4 +- tests/json_rpc_e2e.rs | 70 +++ 20 files changed, 1137 insertions(+), 17 deletions(-) create mode 100644 src/openhuman/tools/impl/browser/playwright_backend.rs create mode 100644 src/openhuman/tools/impl/browser/playwright_runner.mjs diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index df313fc4c..2e18d4514 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -373,9 +373,18 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: . -> target - cache-on-failure: true + cache-on-failure: false shared-key: pr-rust-core-cov + - name: Prune stale coverage test executables + run: | + # The restored target cache can contain prior llvm-cov integration-test + # executables. Those are enormous after coverage instrumentation and + # can exhaust the runner before the current cargo invocation finishes. + find target/llvm-cov-target -type f -path '*/debug/deps/*' -perm -111 -delete 2>/dev/null || true + echo "Disk after coverage target prune:" + df -h /__w || true + - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov @@ -384,6 +393,13 @@ jobs: env: CARGO_BUILD_JOBS: "1" + - name: Prune coverage test executables before cache save + if: always() + run: | + find target/llvm-cov-target -type f -path '*/debug/deps/*' -perm -111 -delete 2>/dev/null || true + echo "Disk after post-coverage prune:" + df -h /__w || true + - name: Upload core lcov uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: diff --git a/Cargo.toml b/Cargo.toml index 3f0d8da09..25a5aa9db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -303,6 +303,9 @@ whatsapp-web = ["dep:whatsapp-rust", "dep:whatsapp-rust-tokio-transport", "dep:w # this feature so the wipe RPC isn't even registered, let alone reachable. e2e-test-support = [] +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } + # Fix whisper-rs-sys CRT mismatch on Windows MSVC (LNK2038). # Upstream cmake build defaults to /MD but Rust uses /MT. # This fork adds config.static_crt(true) to the build script. diff --git a/app/src/components/settings/panels/AutocompleteDebugPanel.tsx b/app/src/components/settings/panels/AutocompleteDebugPanel.tsx index 9336c6e39..ad1b923b9 100644 --- a/app/src/components/settings/panels/AutocompleteDebugPanel.tsx +++ b/app/src/components/settings/panels/AutocompleteDebugPanel.tsx @@ -90,6 +90,7 @@ const AutocompleteDebugPanel = () => { // Live logs const [logs, setLogs] = useState([]); + const isMountedRef = useRef(true); const previousStatusRef = useRef(null); // Personalization history @@ -103,6 +104,7 @@ const AutocompleteDebugPanel = () => { const appendLogs = (entries: string[]) => { if (entries.length === 0) return; + if (!isMountedRef.current) return; const now = new Date(); const stamp = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`; setLogs(current => @@ -179,22 +181,30 @@ const AutocompleteDebugPanel = () => { const loadHistory = async (): Promise => { if (!isTauri()) return []; + if (!isMountedRef.current) return []; setIsHistoryLoading(true); try { const response = await openhumanAutocompleteHistory({ limit: 20 }); + if (!isMountedRef.current) return response.result.entries; setHistoryEntries(response.result.entries); return response.result.entries; } catch { // Non-critical — silently ignore return []; } finally { - setIsHistoryLoading(false); + if (isMountedRef.current) { + setIsHistoryLoading(false); + } } }; useEffect(() => { + isMountedRef.current = true; void load(); void loadHistory(); + return () => { + isMountedRef.current = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -210,6 +220,7 @@ const AutocompleteDebugPanel = () => { } try { const response = await openhumanAutocompleteStatus(); + if (!isMountedRef.current) return response.result; setStatus(response.result); trackStatusChanges(response.result); if (showSpinner) { @@ -344,7 +355,10 @@ const AutocompleteDebugPanel = () => { return; } if (attempt < maxAttempts - 1) { - await new Promise(resolve => window.setTimeout(resolve, 180)); + await new Promise(resolve => globalThis.setTimeout(resolve, 180)); + } + if (!isMountedRef.current) { + return; } } }; @@ -359,6 +373,7 @@ const AutocompleteDebugPanel = () => { suggestion: status?.suggestion?.value ?? undefined, skip_apply: true, }); + if (!isMountedRef.current) return; appendLogs(response.logs); if (response.result.accepted && response.result.value) { setMessage( diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index bab210aac..9a50af9e4 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -150,6 +150,7 @@ export interface RuntimeSettingsUpdate { export interface BrowserSettingsUpdate { enabled?: boolean | null; + backend?: 'agent_browser' | 'playwright' | 'rust_native' | 'computer_use' | 'auto' | null; } export interface ScreenIntelligenceSettingsUpdate { diff --git a/src/openhuman/config/ops/ui.rs b/src/openhuman/config/ops/ui.rs index 00512ba3a..01ae7ed88 100644 --- a/src/openhuman/config/ops/ui.rs +++ b/src/openhuman/config/ops/ui.rs @@ -12,6 +12,7 @@ use super::loader::{fallback_workspace_dir, load_config_with_timeout, snapshot_c #[derive(Debug, Clone, Default)] pub struct BrowserSettingsPatch { pub enabled: Option, + pub backend: Option, } #[derive(Debug, Clone, Default)] @@ -102,9 +103,18 @@ pub async fn apply_browser_settings( config: &mut Config, update: BrowserSettingsPatch, ) -> Result, String> { + let normalized_backend = update + .backend + .as_deref() + .map(normalize_browser_backend) + .transpose()?; + if let Some(enabled) = update.enabled { config.browser.enabled = enabled; } + if let Some(backend) = normalized_backend { + config.browser.backend = backend; + } config.save().await.map_err(|e| e.to_string())?; let snapshot = snapshot_config_json(config)?; Ok(RpcOutcome::new( @@ -116,6 +126,20 @@ pub async fn apply_browser_settings( )) } +fn normalize_browser_backend(raw: &str) -> Result { + let key = raw.trim().to_ascii_lowercase().replace('-', "_"); + match key.as_str() { + "agent_browser" | "agentbrowser" => Ok("agent_browser".to_string()), + "playwright" => Ok("playwright".to_string()), + "rust_native" | "native" => Ok("rust_native".to_string()), + "computer_use" | "computeruse" => Ok("computer_use".to_string()), + "auto" => Ok("auto".to_string()), + _ => Err(format!( + "Unsupported browser backend '{raw}'. Use agent_browser, playwright, rust_native, computer_use, or auto" + )), + } +} + /// Loads the configuration, applies browser settings updates, and saves it. pub async fn load_and_apply_browser_settings( update: BrowserSettingsPatch, diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index fb4a035ab..d6434a492 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -971,6 +971,7 @@ async fn apply_browser_settings_updates_enabled_flag() { &mut cfg, BrowserSettingsPatch { enabled: Some(true), + backend: None, }, ) .await @@ -978,6 +979,47 @@ async fn apply_browser_settings_updates_enabled_flag() { assert!(cfg.browser.enabled); } +#[tokio::test] +async fn apply_browser_settings_updates_backend() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.browser.backend = "agent_browser".into(); + + apply_browser_settings( + &mut cfg, + BrowserSettingsPatch { + enabled: None, + backend: Some("playwright".into()), + }, + ) + .await + .expect("apply"); + + assert_eq!(cfg.browser.backend, "playwright"); +} + +#[tokio::test] +async fn apply_browser_settings_rejects_unknown_backend() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.browser.enabled = false; + cfg.browser.backend = "agent_browser".into(); + + let err = apply_browser_settings( + &mut cfg, + BrowserSettingsPatch { + enabled: Some(true), + backend: Some("netscape".into()), + }, + ) + .await + .expect_err("unknown backend should fail"); + + assert!(err.contains("Unsupported browser backend")); + assert!(!cfg.browser.enabled); + assert_eq!(cfg.browser.backend, "agent_browser"); +} + #[tokio::test] async fn apply_local_ai_settings_updates_lm_studio_provider_fields() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/config/schema/tools/browser.rs b/src/openhuman/config/schema/tools/browser.rs index 4bf40cbf9..72175c9e9 100644 --- a/src/openhuman/config/schema/tools/browser.rs +++ b/src/openhuman/config/schema/tools/browser.rs @@ -73,7 +73,7 @@ fn default_true() -> bool { } fn default_browser_backend() -> String { - "agent_browser".into() + "auto".into() } fn default_browser_webdriver_url() -> String { diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index ca014f7c9..c09b511c0 100644 --- a/src/openhuman/config/schemas/controllers.rs +++ b/src/openhuman/config/schemas/controllers.rs @@ -507,6 +507,7 @@ fn handle_update_browser_settings(params: Map) -> ControllerFutur let update = deserialize_params::(params)?; let patch = config_rpc::BrowserSettingsPatch { enabled: update.enabled, + backend: update.backend, }; to_json(config_rpc::load_and_apply_browser_settings(patch).await?) }) diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index a966664f8..33370a582 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -94,6 +94,7 @@ pub(super) struct RuntimeSettingsUpdate { #[derive(Debug, Deserialize)] pub(super) struct BrowserSettingsUpdate { pub(super) enabled: Option, + pub(super) backend: Option, } #[derive(Debug, Deserialize)] diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index 934f28170..3f9ab16a6 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -253,7 +253,13 @@ pub fn schemas(function: &str) -> ControllerSchema { namespace: "config", function: "update_browser_settings", description: "Update browser automation settings.", - inputs: vec![optional_bool("enabled", "Enable browser integration.")], + inputs: vec![ + optional_bool("enabled", "Enable browser integration."), + optional_string( + "backend", + "Browser backend: agent_browser, playwright, rust_native, computer_use, or auto.", + ), + ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, "update_local_ai_settings" => ControllerSchema { diff --git a/src/openhuman/tools/impl/browser/action_parser.rs b/src/openhuman/tools/impl/browser/action_parser.rs index 5c6cad79a..a3bf36d2f 100644 --- a/src/openhuman/tools/impl/browser/action_parser.rs +++ b/src/openhuman/tools/impl/browser/action_parser.rs @@ -195,6 +195,7 @@ pub(crate) fn is_computer_use_only_action(action: &str) -> bool { pub(crate) fn backend_name(backend: ResolvedBackend) -> &'static str { match backend { ResolvedBackend::AgentBrowser => "agent_browser", + ResolvedBackend::Playwright => "playwright", ResolvedBackend::RustNative => "rust_native", ResolvedBackend::ComputerUse => "computer_use", } diff --git a/src/openhuman/tools/impl/browser/browser.rs b/src/openhuman/tools/impl/browser/browser.rs index cec8136ba..4fc03c52f 100644 --- a/src/openhuman/tools/impl/browser/browser.rs +++ b/src/openhuman/tools/impl/browser/browser.rs @@ -1,8 +1,9 @@ //! Browser automation tool with pluggable backends. //! //! By default this uses Vercel's `agent-browser` tool for automation. -//! Optionally, a Rust-native backend can be enabled at build time via -//! `--features browser-native` and selected through config. +//! Playwright is also supported as a local Node-backed backend. Optionally, a +//! Rust-native backend can be enabled at build time via `--features +//! browser-native` and selected through config. //! Computer-use (OS-level) actions are supported via an optional sidecar endpoint. #[path = "action_parser.rs"] @@ -15,6 +16,7 @@ mod security; #[path = "types.rs"] mod types; +use super::playwright_backend; #[allow(unused_imports)] pub(super) use action_parser::{ backend_name, is_computer_use_only_action, is_supported_browser_action, parse_browser_action, @@ -50,6 +52,7 @@ pub struct BrowserTool { native_webdriver_url: String, native_chrome_path: Option, computer_use: ComputerUseConfig, + playwright_state: tokio::sync::Mutex, #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex, } @@ -92,6 +95,9 @@ impl BrowserTool { native_webdriver_url, native_chrome_path, computer_use, + playwright_state: tokio::sync::Mutex::new( + playwright_backend::PlaywrightBrowserState::default(), + ), #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()), } @@ -114,6 +120,11 @@ impl BrowserTool { Self::is_agent_browser_available().await } + /// Check if the local Playwright package is available to Node.js. + pub async fn is_playwright_available() -> bool { + playwright_backend::PlaywrightBrowserState::is_available().await + } + fn configured_backend(&self) -> anyhow::Result { BrowserBackendKind::parse(&self.backend) } @@ -197,6 +208,15 @@ impl BrowserTool { ) } } + BrowserBackendKind::Playwright => { + if Self::is_playwright_available().await { + Ok(ResolvedBackend::Playwright) + } else { + anyhow::bail!( + "browser.backend='playwright' but Playwright is unavailable. Ensure app/node_modules is installed, run `pnpm --filter openhuman-app exec playwright install chromium-headless-shell`, or set OPENHUMAN_PLAYWRIGHT_CWD." + ) + } + } BrowserBackendKind::RustNative => { if !Self::rust_native_compiled() { anyhow::bail!( @@ -222,6 +242,9 @@ impl BrowserTool { if Self::rust_native_compiled() && self.rust_native_available() { return Ok(ResolvedBackend::RustNative); } + if Self::is_playwright_available().await { + return Ok(ResolvedBackend::Playwright); + } if Self::is_agent_browser_available().await { return Ok(ResolvedBackend::AgentBrowser); } @@ -235,22 +258,22 @@ impl BrowserTool { if Self::rust_native_compiled() { if let Some(err) = computer_use_err { anyhow::bail!( - "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use invalid: {err})" + "browser.backend='auto' found no usable backend (playwright missing, agent-browser missing, rust-native unavailable, computer-use invalid: {err})" ); } anyhow::bail!( - "browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use sidecar unreachable)" + "browser.backend='auto' found no usable backend (playwright missing, agent-browser missing, rust-native unavailable, computer-use sidecar unreachable)" ) } if let Some(err) = computer_use_err { anyhow::bail!( - "browser.backend='auto' needs agent-browser tool, browser-native, or valid computer-use sidecar (error: {err})" + "browser.backend='auto' needs Playwright, agent-browser tool, browser-native, or valid computer-use sidecar (error: {err})" ); } anyhow::bail!( - "browser.backend='auto' needs agent-browser tool, browser-native, or computer-use sidecar" + "browser.backend='auto' needs Playwright, agent-browser tool, browser-native, or computer-use sidecar" ) } } @@ -515,6 +538,30 @@ impl BrowserTool { } } + async fn execute_playwright_action(&self, action: BrowserAction) -> anyhow::Result { + if let BrowserAction::Open { url } = &action { + debug!("[browser] validating playwright open url before dispatch"); + self.validate_url(url)?; + } + + let mut state = self.playwright_state.lock().await; + let output = state + .execute_action( + action, + self.native_headless, + Some(playwright_backend::BrowserUrlPolicy { + allowed_domains: self.allowed_domains.clone(), + allow_all: allow_all_browser_domains(), + }), + ) + .await + .context("Playwright browser action failed")?; + + Ok(ToolResult::success( + serde_json::to_string_pretty(&output).unwrap_or_default(), + )) + } + fn validate_coordinate(&self, key: &str, value: i64, max: Option) -> anyhow::Result<()> { if value < 0 { anyhow::bail!("'{key}' must be >= 0") @@ -679,6 +726,7 @@ impl BrowserTool { ) -> anyhow::Result { match backend { ResolvedBackend::AgentBrowser => self.execute_agent_browser_action(action).await, + ResolvedBackend::Playwright => self.execute_playwright_action(action).await, ResolvedBackend::RustNative => self.execute_rust_native_action(action).await, ResolvedBackend::ComputerUse => anyhow::bail!( "Internal error: computer_use backend must be handled before BrowserAction parsing" @@ -708,7 +756,7 @@ impl Tool for BrowserTool { fn description(&self) -> &str { concat!( - "Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). ", + "Web/browser automation with pluggable backends (playwright, agent-browser, rust-native, computer_use). ", "Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, ", "key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map ", "interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions." diff --git a/src/openhuman/tools/impl/browser/browser_tests.rs b/src/openhuman/tools/impl/browser/browser_tests.rs index 4e3a65c73..a768da1bf 100644 --- a/src/openhuman/tools/impl/browser/browser_tests.rs +++ b/src/openhuman/tools/impl/browser/browser_tests.rs @@ -128,6 +128,10 @@ fn browser_backend_parser_accepts_supported_values() { BrowserBackendKind::parse("agent_browser").unwrap(), BrowserBackendKind::AgentBrowser ); + assert_eq!( + BrowserBackendKind::parse("playwright").unwrap(), + BrowserBackendKind::Playwright + ); assert_eq!( BrowserBackendKind::parse("rust-native").unwrap(), BrowserBackendKind::RustNative @@ -144,11 +148,11 @@ fn browser_backend_parser_accepts_supported_values() { #[test] fn browser_backend_parser_rejects_unknown_values() { - assert!(BrowserBackendKind::parse("playwright").is_err()); + assert!(BrowserBackendKind::parse("netscape").is_err()); } #[test] -fn browser_tool_default_backend_is_agent_browser() { +fn browser_tool_default_backend_is_agent_browser_for_legacy_constructor() { let security = Arc::new(SecurityPolicy::default()); let tool = BrowserTool::new(security, vec!["example.com".into()], None); assert_eq!( @@ -173,6 +177,53 @@ fn browser_tool_accepts_auto_backend_config() { assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto); } +#[test] +fn browser_tool_accepts_playwright_backend_config() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "playwright".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig::default(), + ); + assert_eq!( + tool.configured_backend().unwrap(), + BrowserBackendKind::Playwright + ); +} + +#[tokio::test] +async fn playwright_backend_validates_open_url_before_runner() { + let security = Arc::new(SecurityPolicy::default()); + let tool = BrowserTool::new_with_backend( + security, + vec!["example.com".into()], + None, + "playwright".into(), + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig::default(), + ); + + let err = tool + .execute_action( + BrowserAction::Open { + url: "file:///tmp/secret.txt".into(), + }, + ResolvedBackend::Playwright, + ) + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("file:// URLs are not allowed")); +} + #[test] fn browser_tool_accepts_computer_use_backend_config() { let security = Arc::new(SecurityPolicy::default()); @@ -606,6 +657,7 @@ fn supported_action_detection_is_exhaustive() { #[test] fn browser_backend_kind_as_str_roundtrips() { assert_eq!(BrowserBackendKind::AgentBrowser.as_str(), "agent_browser"); + assert_eq!(BrowserBackendKind::Playwright.as_str(), "playwright"); assert_eq!(BrowserBackendKind::RustNative.as_str(), "rust_native"); assert_eq!(BrowserBackendKind::ComputerUse.as_str(), "computer_use"); assert_eq!(BrowserBackendKind::Auto.as_str(), "auto"); @@ -749,6 +801,7 @@ fn validate_coordinate_no_limit_allows_any_non_negative() { #[test] fn backend_name_covers_all_variants() { assert_eq!(backend_name(ResolvedBackend::AgentBrowser), "agent_browser"); + assert_eq!(backend_name(ResolvedBackend::Playwright), "playwright"); assert_eq!(backend_name(ResolvedBackend::RustNative), "rust_native"); assert_eq!(backend_name(ResolvedBackend::ComputerUse), "computer_use"); } diff --git a/src/openhuman/tools/impl/browser/mod.rs b/src/openhuman/tools/impl/browser/mod.rs index 9300ea221..8693c8521 100644 --- a/src/openhuman/tools/impl/browser/mod.rs +++ b/src/openhuman/tools/impl/browser/mod.rs @@ -3,6 +3,7 @@ mod browser; mod browser_open; mod image_info; mod image_output; +mod playwright_backend; mod screenshot; pub use browser::{BrowserAction, BrowserTool, ComputerUseConfig}; diff --git a/src/openhuman/tools/impl/browser/playwright_backend.rs b/src/openhuman/tools/impl/browser/playwright_backend.rs new file mode 100644 index 000000000..235ef1eb6 --- /dev/null +++ b/src/openhuman/tools/impl/browser/playwright_backend.rs @@ -0,0 +1,407 @@ +use super::BrowserAction; +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::time::{timeout, Duration}; + +const RUNNER_JS: &str = include_str!("playwright_runner.mjs"); +const PLAYWRIGHT_PROBE_TIMEOUT: Duration = Duration::from_secs(20); +const PLAYWRIGHT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Default)] +pub struct PlaywrightBrowserState { + daemon: Option, + next_id: u64, +} + +struct PlaywrightDaemon { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +#[derive(Debug, Deserialize)] +struct PlaywrightResponse { + #[serde(default)] + id: Option, + success: bool, + #[serde(default)] + data: Option, + #[serde(default)] + error: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct BrowserUrlPolicy { + pub(crate) allowed_domains: Vec, + pub(crate) allow_all: bool, +} + +impl Drop for PlaywrightDaemon { + fn drop(&mut self) { + tracing::debug!("[browser::playwright] dropping backend daemon"); + let _ = self.child.start_kill(); + } +} + +impl PlaywrightBrowserState { + pub async fn is_available() -> bool { + tracing::debug!("[browser::playwright] probing playwright runtime availability"); + let mut command = node_command(); + command + .args([ + "-e", + "const load=()=>{try{return require('playwright')}catch(_){return require('@playwright/test')}};(async()=>{const {chromium}=load();const browser=await chromium.launch({headless:true});await browser.close();})().then(()=>process.exit(0)).catch(()=>process.exit(1));", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + apply_node_cwd(&mut command); + + match timeout(PLAYWRIGHT_PROBE_TIMEOUT, command.status()).await { + Ok(Ok(status)) => { + let available = status.success(); + tracing::debug!( + available, + status = ?status.code(), + "[browser::playwright] runtime availability probe finished" + ); + available + } + Ok(Err(error)) => { + tracing::debug!( + error = %error, + "[browser::playwright] runtime availability probe failed to start" + ); + false + } + Err(_) => { + tracing::debug!( + timeout_ms = PLAYWRIGHT_PROBE_TIMEOUT.as_millis() as u64, + "[browser::playwright] runtime availability probe timed out" + ); + false + } + } + } + + pub async fn execute_action( + &mut self, + action: BrowserAction, + headless: bool, + url_policy: Option, + ) -> Result { + tracing::trace!("[browser::playwright] preparing action request"); + let args = action_to_args(action, url_policy); + self.execute_args(args, headless).await + } + + async fn execute_args(&mut self, args: Value, headless: bool) -> Result { + if self.daemon.is_none() { + tracing::debug!("[browser::playwright] starting playwright backend daemon"); + self.daemon = Some(start_daemon(headless).await?); + } + + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let action = args + .get("action") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + + let request = json!({ + "id": id, + "args": args, + }); + let line = serde_json::to_vec(&request).context("Failed to encode Playwright request")?; + tracing::debug!( + request_id = id, + action = %action, + "[browser::playwright] dispatching action" + ); + + let daemon = self.daemon.as_mut().expect("daemon just initialized"); + if let Err(error) = write_request(daemon, &line).await { + tracing::debug!( + error = %error, + request_id = id, + "[browser::playwright] daemon write failed; restarting once" + ); + self.daemon = Some(start_daemon(headless).await?); + let daemon = self.daemon.as_mut().expect("daemon restarted"); + if let Err(error) = write_request(daemon, &line).await { + tracing::debug!( + error = %error, + request_id = id, + "[browser::playwright] daemon write retry failed; dropping daemon" + ); + self.daemon = None; + return Err(error); + } + } + + let daemon = self.daemon.as_mut().expect("daemon available"); + let response = match timeout(PLAYWRIGHT_RESPONSE_TIMEOUT, read_response(daemon)).await { + Ok(Ok(response)) => response, + Ok(Err(error)) => { + tracing::debug!( + error = %error, + request_id = id, + "[browser::playwright] daemon read failed; dropping daemon" + ); + self.daemon = None; + return Err(error).context("Failed to read Playwright response"); + } + Err(_) => { + tracing::debug!( + request_id = id, + timeout_ms = PLAYWRIGHT_RESPONSE_TIMEOUT.as_millis() as u64, + "[browser::playwright] daemon response timed out; dropping daemon" + ); + self.daemon = None; + anyhow::bail!("Timed out waiting for Playwright response"); + } + }; + + if response.id != Some(id) { + tracing::debug!( + expected_id = id, + response_id = ?response.id, + "[browser::playwright] daemon response id mismatch; dropping daemon" + ); + self.daemon = None; + anyhow::bail!("Playwright daemon response id mismatch"); + } + + if response.success { + tracing::debug!( + request_id = id, + action = %action, + "[browser::playwright] action completed" + ); + Ok(response.data.unwrap_or_else(|| json!({ "ok": true }))) + } else { + tracing::debug!( + request_id = id, + action = %action, + error = ?response.error, + "[browser::playwright] action failed" + ); + anyhow::bail!( + "{}", + response + .error + .unwrap_or_else(|| "Playwright backend failed".to_string()) + ) + } + } +} + +async fn write_request(daemon: &mut PlaywrightDaemon, line: &[u8]) -> Result<()> { + tracing::trace!("[browser::playwright] writing request to daemon"); + daemon + .stdin + .write_all(line) + .await + .context("Failed to write Playwright request")?; + daemon + .stdin + .write_all(b"\n") + .await + .context("Failed to terminate Playwright request")?; + daemon + .stdin + .flush() + .await + .context("Failed to flush Playwright request")?; + Ok(()) +} + +async fn read_response(daemon: &mut PlaywrightDaemon) -> Result { + tracing::trace!("[browser::playwright] reading response from daemon"); + let mut line = String::new(); + let read = daemon + .stdout + .read_line(&mut line) + .await + .context("Failed to read Playwright stdout")?; + if read == 0 { + anyhow::bail!("Playwright daemon exited without a response"); + } + tracing::trace!("[browser::playwright] received response from daemon"); + serde_json::from_str(&line).context("Playwright daemon returned invalid JSON") +} + +async fn start_daemon(headless: bool) -> Result { + tracing::debug!( + headless, + "[browser::playwright] spawning playwright backend daemon" + ); + let mut command = node_command(); + command + .arg("-e") + .arg(RUNNER_JS) + .env( + "OPENHUMAN_PLAYWRIGHT_HEADLESS", + if headless { "1" } else { "0" }, + ) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + apply_node_cwd(&mut command); + + let mut child = command + .spawn() + .map_err(|error| { + tracing::debug!( + error = %error, + "[browser::playwright] failed to spawn backend daemon" + ); + error + }) + .context( + "Failed to start Playwright backend. Ensure Node.js and the Playwright package are installed.", + )?; + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("Playwright daemon stdin unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("Playwright daemon stdout unavailable"))?; + + if let Some(stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!("[browser::playwright] stderr: {line}"); + } + }); + } + + tracing::debug!("[browser::playwright] backend daemon spawned"); + Ok(PlaywrightDaemon { + child, + stdin, + stdout: BufReader::new(stdout), + }) +} + +fn node_command() -> Command { + let binary = std::env::var("OPENHUMAN_PLAYWRIGHT_NODE").unwrap_or_else(|_| "node".to_string()); + Command::new(binary) +} + +fn apply_node_cwd(command: &mut Command) { + if let Some(cwd) = playwright_node_cwd() { + command.current_dir(cwd); + } +} + +fn playwright_node_cwd() -> Option { + if let Ok(raw) = std::env::var("OPENHUMAN_PLAYWRIGHT_CWD") { + let path = PathBuf::from(raw); + if path.exists() { + return Some(path); + } + } + + let app = Path::new("app"); + if app.join("node_modules").exists() { + return Some(app.to_path_buf()); + } + + None +} + +fn action_to_args(action: BrowserAction, url_policy: Option) -> Value { + match action { + BrowserAction::Open { url } => { + let policy = url_policy.expect("playwright open actions require a URL policy"); + json!({ + "action": "open", + "url": url, + "url_policy": { + "allowed_domains": policy.allowed_domains, + "allow_all": policy.allow_all, + }, + }) + } + BrowserAction::Snapshot { + interactive_only, + compact, + depth, + } => json!({ + "action": "snapshot", + "interactive_only": interactive_only, + "compact": compact, + "depth": depth, + }), + BrowserAction::Click { selector } => json!({ "action": "click", "selector": selector }), + BrowserAction::Fill { selector, value } => { + json!({ "action": "fill", "selector": selector, "value": value }) + } + BrowserAction::Type { selector, text } => { + json!({ "action": "type", "selector": selector, "text": text }) + } + BrowserAction::GetText { selector } => { + json!({ "action": "get_text", "selector": selector }) + } + BrowserAction::GetTitle => json!({ "action": "get_title" }), + BrowserAction::GetUrl => json!({ "action": "get_url" }), + BrowserAction::Screenshot { path, full_page } => { + json!({ "action": "screenshot", "path": path, "full_page": full_page }) + } + BrowserAction::Wait { selector, ms, text } => { + json!({ "action": "wait", "selector": selector, "ms": ms, "text": text }) + } + BrowserAction::Press { key } => json!({ "action": "press", "key": key }), + BrowserAction::Hover { selector } => json!({ "action": "hover", "selector": selector }), + BrowserAction::Scroll { direction, pixels } => { + json!({ "action": "scroll", "direction": direction, "pixels": pixels }) + } + BrowserAction::IsVisible { selector } => { + json!({ "action": "is_visible", "selector": selector }) + } + BrowserAction::Close => json!({ "action": "close" }), + BrowserAction::Find { + by, + value, + action, + fill_value, + } => json!({ + "action": "find", + "by": by, + "value": value, + "find_action": action, + "fill_value": fill_value, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_to_args_preserves_find_shape() { + let args = action_to_args( + BrowserAction::Find { + by: "label".into(), + value: "Email".into(), + action: "fill".into(), + fill_value: Some("a@example.com".into()), + }, + None, + ); + + assert_eq!(args["action"], "find"); + assert_eq!(args["find_action"], "fill"); + assert_eq!(args["fill_value"], "a@example.com"); + } +} diff --git a/src/openhuman/tools/impl/browser/playwright_runner.mjs b/src/openhuman/tools/impl/browser/playwright_runner.mjs new file mode 100644 index 000000000..a7102e885 --- /dev/null +++ b/src/openhuman/tools/impl/browser/playwright_runner.mjs @@ -0,0 +1,422 @@ +const dns = require('node:dns').promises; +const fs = require('node:fs'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); +const readline = require('node:readline'); + +let chromium; +try { + ({ chromium } = require('playwright')); +} catch (error) { + ({ chromium } = require('@playwright/test')); +} + +const userDataDir = + process.env.OPENHUMAN_PLAYWRIGHT_USER_DATA_DIR || + path.join(os.tmpdir(), 'openhuman-playwright-browser'); +const headless = process.env.OPENHUMAN_PLAYWRIGHT_HEADLESS !== '0'; + +let context = null; +let page = null; +let refs = new Map(); + +function write(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +async function ensurePage() { + if (!context) { + fs.mkdirSync(userDataDir, { recursive: true }); + context = await chromium.launchPersistentContext(userDataDir, { + headless, + viewport: { width: 1365, height: 900 }, + }); + } + + if (!page || page.isClosed()) { + page = context.pages()[0] || (await context.newPage()); + } + + return page; +} + +function cssPath(el) { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.body) { + const tag = node.tagName.toLowerCase(); + if (node.id) { + parts.unshift(`${tag}#${CSS.escape(node.id)}`); + break; + } + const parent = node.parentElement; + if (!parent) break; + const siblings = Array.from(parent.children).filter(child => child.tagName === node.tagName); + const index = siblings.indexOf(node) + 1; + parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${index})` : tag); + node = parent; + } + return parts.length ? parts.join(' > ') : 'body'; +} + +async function snapshot(interactiveOnly, compact, depth) { + return await page.evaluate( + ({ interactiveOnly, compact, depth }) => { + const isInteractive = el => { + const tag = el.tagName.toLowerCase(); + const role = el.getAttribute('role'); + return ( + ['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(tag) || + ['button', 'link', 'checkbox', 'menuitem', 'option', 'radio', 'switch', 'tab'].includes(role || '') || + el.hasAttribute('onclick') || + el.tabIndex >= 0 + ); + }; + + const nameFor = el => + (el.getAttribute('aria-label') || + el.getAttribute('alt') || + el.getAttribute('title') || + el.getAttribute('placeholder') || + el.innerText || + el.textContent || + '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); + + const cssPath = el => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.body) { + const tag = node.tagName.toLowerCase(); + if (node.id) { + parts.unshift(`${tag}#${CSS.escape(node.id)}`); + break; + } + const parent = node.parentElement; + if (!parent) break; + const siblings = Array.from(parent.children).filter(child => child.tagName === node.tagName); + const index = siblings.indexOf(node) + 1; + parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${index})` : tag); + node = parent; + } + return parts.length ? parts.join(' > ') : 'body'; + }; + + const elements = []; + const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT); + let node = walker.currentNode; + let id = 1; + while (node) { + const rect = node.getBoundingClientRect(); + const visible = rect.width > 0 && rect.height > 0; + const interactive = isInteractive(node); + const name = nameFor(node); + if (visible && (!interactiveOnly || interactive) && (!compact || name || interactive)) { + elements.push({ + ref: `@e${id++}`, + tag: node.tagName.toLowerCase(), + role: node.getAttribute('role') || undefined, + name, + selector: cssPath(node), + }); + } + if (depth && elements.length >= depth * 80) break; + node = walker.nextNode(); + } + return { + title: document.title, + url: location.href, + elements, + }; + }, + { interactiveOnly, compact, depth }, + ); +} + +async function selectorFor(input) { + if (typeof input === 'string' && input.startsWith('@')) { + const selector = refs.get(input); + if (!selector) throw new Error(`Unknown element ref: ${input}. Run snapshot first.`); + return selector; + } + return input; +} + +async function runFind(args) { + const current = await ensurePage(); + const value = args.value || ''; + let locator; + switch (args.by) { + case 'role': + locator = current.getByRole(value); + break; + case 'text': + locator = current.getByText(value); + break; + case 'label': + locator = current.getByLabel(value); + break; + case 'placeholder': + locator = current.getByPlaceholder(value); + break; + case 'testid': + locator = current.getByTestId(value); + break; + default: + throw new Error(`Unsupported find locator: ${args.by}`); + } + + const first = locator.first(); + switch (args.find_action) { + case 'click': + await first.click(); + return { action: 'find', by: args.by, find_action: 'click' }; + case 'fill': + await first.fill(args.fill_value || ''); + return { action: 'find', by: args.by, find_action: 'fill' }; + case 'hover': + await first.hover(); + return { action: 'find', by: args.by, find_action: 'hover' }; + case 'text': + return { action: 'find', by: args.by, find_action: 'text', text: await first.innerText() }; + case 'check': + await first.check(); + return { action: 'find', by: args.by, find_action: 'check' }; + default: + throw new Error(`Unsupported find action: ${args.find_action}`); + } +} + +function ipv4Parts(host) { + const parts = host.split('.'); + if (parts.length !== 4) return null; + const nums = parts.map(part => Number(part)); + if (nums.some(num => !Number.isInteger(num) || num < 0 || num > 255)) return null; + return nums; +} + +function isPrivateIpv4(host) { + const parts = ipv4Parts(host); + if (!parts) return false; + const [a, b] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + (a === 192 && b === 0 && parts[2] === 0) || + (a === 192 && b === 0 && parts[2] === 2) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && parts[2] === 100) || + (a === 203 && b === 0 && parts[2] === 113) || + a >= 224 + ); +} + +function mappedIpv4FromIpv6Tail(tail) { + if (tail.includes('.')) return tail; + const groups = tail.split(':').filter(Boolean); + if (groups.length < 2) return null; + const high = Number.parseInt(groups[groups.length - 2], 16); + const low = Number.parseInt(groups[groups.length - 1], 16); + if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || high > 0xffff || low < 0 || low > 0xffff) { + return null; + } + return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`; +} + +function isPrivateIpv6(host) { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === '::1' || normalized === '::') return true; + if (normalized.startsWith('::ffff:')) { + const mapped = mappedIpv4FromIpv6Tail(normalized.slice('::ffff:'.length)); + return !mapped || isPrivateIpv4(mapped); + } + return ( + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe8') || + normalized.startsWith('fe9') || + normalized.startsWith('fea') || + normalized.startsWith('feb') + ); +} + +function isPrivateHost(host) { + const normalized = String(host || '').toLowerCase().replace(/^\[|\]$/g, ''); + if (!normalized) return true; + if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized.endsWith('.local')) return true; + const ipVersion = net.isIP(normalized); + if (ipVersion === 4) return isPrivateIpv4(normalized); + if (ipVersion === 6) return isPrivateIpv6(normalized); + return false; +} + +async function assertDnsDoesNotResolvePrivate(host) { + if (net.isIP(host)) return; + let addresses; + try { + addresses = await dns.lookup(host, { all: true, verbatim: true }); + } catch (error) { + throw new Error(`Failed to resolve host '${host}': ${error.message}`); + } + for (const entry of addresses) { + if (isPrivateHost(entry.address)) { + throw new Error(`Host '${host}' resolves to blocked local/private address: ${entry.address}`); + } + } +} + +function hostMatchesAllowlist(host, allowedDomains) { + const normalizedHost = String(host || '').toLowerCase(); + return allowedDomains.some(domain => { + const allowed = String(domain || '').trim().toLowerCase(); + if (!allowed) return false; + if (allowed === '*') return true; + if (allowed.startsWith('*.')) { + const suffix = allowed.slice(2); + return normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`); + } + return normalizedHost === allowed || normalizedHost.endsWith(`.${allowed}`); + }); +} + +async function authorizeUrl(rawUrl, policy) { + if (!policy) throw new Error('Missing browser URL policy'); + const trimmed = String(rawUrl || '').trim(); + if (!trimmed) throw new Error('URL cannot be empty'); + if (trimmed.startsWith('file://')) throw new Error('file:// URLs are not allowed in browser automation'); + + let parsed; + try { + parsed = new URL(trimmed); + } catch (error) { + throw new Error(`Invalid URL: ${error.message}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Only http:// and https:// URLs are allowed'); + } + + const host = parsed.hostname.toLowerCase(); + if (isPrivateHost(host)) throw new Error(`Blocked local/private host: ${host}`); + await assertDnsDoesNotResolvePrivate(host); + + const allowedDomains = Array.isArray(policy.allowed_domains) ? policy.allowed_domains : []; + if (allowedDomains.length === 0 && !policy.allow_all) { + throw new Error('Browser tool enabled but no allowed_domains configured'); + } + if (!policy.allow_all && allowedDomains.length > 0 && !hostMatchesAllowlist(host, allowedDomains)) { + throw new Error(`Host '${host}' not in browser.allowed_domains`); + } +} + +async function run(args) { + const current = await ensurePage(); + switch (args.action) { + case 'open': + await authorizeUrl(args.url, args.url_policy); + await current.goto(args.url, { waitUntil: 'domcontentloaded' }); + try { + await authorizeUrl(current.url(), args.url_policy); + } catch (error) { + await current.goto('about:blank').catch(() => {}); + throw new Error(`Redirect blocked: ${error.message}`); + } + return { backend: 'playwright', action: 'open', url: current.url(), title: await current.title() }; + case 'snapshot': { + const data = await snapshot(args.interactive_only ?? true, args.compact ?? true, args.depth); + refs = new Map(data.elements.map(element => [element.ref, element.selector])); + return { backend: 'playwright', action: 'snapshot', ...data }; + } + case 'click': + await current.locator(await selectorFor(args.selector)).first().click(); + return { backend: 'playwright', action: 'click', selector: args.selector }; + case 'fill': + await current.locator(await selectorFor(args.selector)).first().fill(args.value || ''); + return { backend: 'playwright', action: 'fill', selector: args.selector }; + case 'type': + await current.locator(await selectorFor(args.selector)).first().type(args.text || ''); + return { backend: 'playwright', action: 'type', selector: args.selector, typed: (args.text || '').length }; + case 'get_text': + return { + backend: 'playwright', + action: 'get_text', + selector: args.selector, + text: await current.locator(await selectorFor(args.selector)).first().innerText(), + }; + case 'get_title': + return { backend: 'playwright', action: 'get_title', title: await current.title() }; + case 'get_url': + return { backend: 'playwright', action: 'get_url', url: current.url() }; + case 'screenshot': { + const png = await current.screenshot({ fullPage: Boolean(args.full_page) }); + if (args.path) { + throw new Error('Playwright screenshot path writes require Rust-side path validation and are disabled'); + } + return { backend: 'playwright', action: 'screenshot', png_base64: png.toString('base64'), bytes: png.length }; + } + case 'wait': + if (args.selector) await current.locator(await selectorFor(args.selector)).first().waitFor(); + else if (args.text) await current.getByText(args.text).first().waitFor(); + else await current.waitForTimeout(args.ms || 1000); + return { backend: 'playwright', action: 'wait' }; + case 'press': + await current.keyboard.press(args.key); + return { backend: 'playwright', action: 'press', key: args.key }; + case 'hover': + await current.locator(await selectorFor(args.selector)).first().hover(); + return { backend: 'playwright', action: 'hover', selector: args.selector }; + case 'scroll': + await current.mouse.wheel( + args.direction === 'left' ? -(args.pixels || 600) : args.direction === 'right' ? args.pixels || 600 : 0, + args.direction === 'up' ? -(args.pixels || 600) : args.direction === 'down' ? args.pixels || 600 : 0, + ); + return { backend: 'playwright', action: 'scroll', direction: args.direction }; + case 'is_visible': + return { + backend: 'playwright', + action: 'is_visible', + selector: args.selector, + visible: await current.locator(await selectorFor(args.selector)).first().isVisible(), + }; + case 'find': + return { backend: 'playwright', ...(await runFind(args)) }; + case 'close': + if (context) await context.close(); + context = null; + page = null; + refs = new Map(); + return { backend: 'playwright', action: 'close' }; + default: + throw new Error(`Unsupported action: ${args.action}`); + } +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', async line => { + let request; + try { + request = JSON.parse(line); + const data = await run(request.args || {}); + write({ id: request.id, success: true, data }); + } catch (error) { + write({ id: request && request.id, success: false, error: error && error.message ? error.message : String(error) }); + } +}); + +process.on('SIGTERM', async () => { + try { + if (context) await context.close(); + } finally { + process.exit(0); + } +}); diff --git a/src/openhuman/tools/impl/browser/types.rs b/src/openhuman/tools/impl/browser/types.rs index 1176b7f85..fb44bcf7a 100644 --- a/src/openhuman/tools/impl/browser/types.rs +++ b/src/openhuman/tools/impl/browser/types.rs @@ -44,6 +44,7 @@ impl Default for ComputerUseConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum BrowserBackendKind { AgentBrowser, + Playwright, RustNative, ComputerUse, Auto, @@ -52,6 +53,7 @@ pub(crate) enum BrowserBackendKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ResolvedBackend { AgentBrowser, + Playwright, RustNative, ComputerUse, } @@ -61,11 +63,12 @@ impl BrowserBackendKind { let key = raw.trim().to_ascii_lowercase().replace('-', "_"); match key.as_str() { "agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser), + "playwright" => Ok(Self::Playwright), "rust_native" | "native" => Ok(Self::RustNative), "computer_use" | "computeruse" => Ok(Self::ComputerUse), "auto" => Ok(Self::Auto), _ => anyhow::bail!( - "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'" + "Unsupported browser backend '{raw}'. Use 'agent_browser', 'playwright', 'rust_native', 'computer_use', or 'auto'" ), } } @@ -73,6 +76,7 @@ impl BrowserBackendKind { pub(crate) fn as_str(self) -> &'static str { match self { Self::AgentBrowser => "agent_browser", + Self::Playwright => "playwright", Self::RustNative => "rust_native", Self::ComputerUse => "computer_use", Self::Auto => "auto", diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 1992f2c74..69eb4a936 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -101,6 +101,11 @@ //! Assertion is implicit: cargo reports the test as failed when the //! tokio runtime aborts with stack overflow. +// This stress target is still compiled by normal cargo test. Under llvm-cov, +// coverage instrumentation makes the already-large integration-test binary +// trip CI's linker with SIGBUS before the regression can run. +#![cfg(not(coverage))] + use anyhow::Result; use async_trait::async_trait; use openhuman_core::openhuman::agent::harness::definition::{AgentDefinitionRegistry, ModelSpec}; diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index f35eaffad..103fb171c 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -39,7 +39,7 @@ use openhuman_core::openhuman::agent::harness::definition::{ }; use openhuman_core::openhuman::agent::harness::subagent_runner::{ autonomous_iter_cap, with_autonomous_iter_cap, SubagentMode, SubagentRunError, - SubagentRunOptions, SubagentRunOutcome, SubagentRunStatus, + SubagentRunOptions, SubagentRunOutcome, SubagentRunStatus, SubagentUsage, }; use openhuman_core::openhuman::agent::harness::{ check_interrupt, current_sandbox_mode, with_current_sandbox_mode, InterruptFence, @@ -4704,7 +4704,7 @@ async fn agent_subagent_public_types_cover_task_local_and_error_display_paths() mode: SubagentMode::Typed, status: SubagentRunStatus::Completed, final_history: Vec::new(), - usage: Default::default(), + usage: SubagentUsage::default(), }; assert_eq!(outcome.mode.as_str(), "typed"); assert_eq!(outcome.elapsed.as_millis(), 12); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 4cd23bda5..59d81e4c2 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -981,6 +981,76 @@ fn ensure_test_rpc_auth() { }); } +#[tokio::test] +async fn json_rpc_config_update_browser_settings_persists_backend() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + write_min_config(&openhuman_home, "http://127.0.0.1:9"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + let updated = post_json_rpc( + &rpc_base, + 4124_1, + "openhuman.config_update_browser_settings", + json!({ + "enabled": true, + "backend": "playwright" + }), + ) + .await; + let updated_result = + assert_no_jsonrpc_error(&updated, "config_update_browser_settings backend"); + let updated_snapshot = peel_logs_envelope(updated_result); + assert_eq!( + updated_snapshot + .pointer("/config/browser/enabled") + .and_then(Value::as_bool), + Some(true), + "browser enabled flag should persist in update response: {updated_snapshot}" + ); + assert_eq!( + updated_snapshot + .pointer("/config/browser/backend") + .and_then(Value::as_str), + Some("playwright"), + "browser backend should persist in update response: {updated_snapshot}" + ); + + let get = post_json_rpc(&rpc_base, 4124_2, "openhuman.config_get", json!({})).await; + let get_result = assert_no_jsonrpc_error(&get, "config_get"); + let snapshot = peel_logs_envelope(get_result); + assert_eq!( + snapshot + .pointer("/config/browser/backend") + .and_then(Value::as_str), + Some("playwright"), + "browser backend should persist in config_get response: {snapshot}" + ); + + let invalid = post_json_rpc( + &rpc_base, + 4124_3, + "openhuman.config_update_browser_settings", + json!({ + "backend": "netscape" + }), + ) + .await; + assert_jsonrpc_error(&invalid, "invalid browser backend"); + + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_tokenjuice_detect_and_cache_stats() { let _env_lock = json_rpc_e2e_env_lock();