From 132785688945280b3eee6ef6dc035f41698c9835 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 13 Mar 2026 01:38:57 -0500 Subject: [PATCH] feat: add browserless provider integration to native browser implementation (#746) * feat: add browserless provider integration to native browser implementation This PR adds support for the Browserless provider to the native browser implementation, expanding the available remote browser providers from 3 to 4. ## Changes Made - **Added `connect_browserless()` function**: Implements session creation with Browserless API using environment variables for configuration - **Updated provider routing**: Added "browserless" case to the main provider switch statement - **Added session cleanup**: Implemented proper session termination using the stop URL returned by Browserless - **Updated documentation**: Modified comments and error messages to include Browserless in the supported provider list - **Environment variable support**: Added support for configurable Browserless settings including API key, URL, browser type, TTL, and stealth mode ## Implementation Details - Uses standard Browserless session API with POST to create sessions and DELETE to terminate - Supports both chromium and chrome browser types with validation - Includes proper error handling for API failures and missing configuration - Stores the stop URL as session_id for cleanup purposes - Follows the existing provider pattern for consistency Fixes #744 * fix: URL-encode API key in browserless session request Use reqwest's .query() method instead of string-formatting the token directly into the URL, matching the Node.js implementation's use of encodeURIComponent. Prevents malformed URLs if the API key contains special characters. --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com> --- cli/src/native/providers.rs | 92 +++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/cli/src/native/providers.rs b/cli/src/native/providers.rs index 66a3d3f..d29703e 100644 --- a/cli/src/native/providers.rs +++ b/cli/src/native/providers.rs @@ -1,7 +1,7 @@ //! Browser provider connections for remote CDP sessions. //! -//! Supports Browserbase, Browser Use, and Kernel providers. Each provider -//! returns a CDP WebSocket URL for connecting via BrowserManager. +//! Supports Browserbase, Browserless, Browser Use, and Kernel providers. +//! Each provider returns a CDP WebSocket URL for connecting via BrowserManager. use serde_json::{json, Value}; use std::env; @@ -19,10 +19,11 @@ pub async fn connect_provider( ) -> Result<(String, Option), String> { match provider_name.to_lowercase().as_str() { "browserbase" => connect_browserbase().await, + "browserless" => connect_browserless().await, "browser-use" | "browseruse" => connect_browser_use().await, "kernel" => connect_kernel().await, _ => Err(format!( - "Unknown provider '{}'. Supported: browserbase, browser-use, kernel", + "Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel", provider_name )), } @@ -60,6 +61,10 @@ pub async fn close_provider_session(session: &ProviderSession) { .await; } } + "browserless" => { + // session_id holds the stop URL for browserless + let _ = client.delete(&session.session_id).send().await; + } "kernel" => { if let Ok(api_key) = env::var("KERNEL_API_KEY") { let endpoint = env::var("KERNEL_ENDPOINT") @@ -129,6 +134,87 @@ async fn connect_browserbase() -> Result<(String, Option), Stri )) } +async fn connect_browserless() -> Result<(String, Option), String> { + let api_key = env::var("BROWSERLESS_API_KEY") + .map_err(|_| "BROWSERLESS_API_KEY environment variable is not set")?; + + let api_url = env::var("BROWSERLESS_API_URL") + .unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string()); + let browser_type = + env::var("BROWSERLESS_BROWSER_TYPE").unwrap_or_else(|_| "chromium".to_string()); + + let supported = ["chromium", "chrome"]; + if !supported.contains(&browser_type.as_str()) { + return Err(format!( + "BROWSERLESS_BROWSER_TYPE \"{}\" is not supported. Only {} are allowed.", + browser_type, + supported.join(", ") + )); + } + + let ttl: u64 = env::var("BROWSERLESS_TTL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(300000); + let stealth = env::var("BROWSERLESS_STEALTH") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(true); + + let url = format!("{}/session", api_url.trim_end_matches('/')); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .query(&[("token", &api_key)]) + .header("Content-Type", "application/json") + .json(&json!({ + "ttl": ttl, + "stealth": stealth, + "browser": browser_type, + })) + .send() + .await + .map_err(|e| format!("Browserless request failed: {}", e))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("Failed to read Browserless response: {}", e))?; + + if !status.is_success() { + return Err(format!( + "Browserless API error ({}): {}", + status.as_u16(), + body + )); + } + + let json: Value = + serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?; + + let connect_url = json + .get("connect") + .and_then(|v| v.as_str()) + .map(String::from) + .ok_or_else(|| "Browserless response missing 'connect' URL".to_string())?; + + let stop_url = json + .get("stop") + .and_then(|v| v.as_str()) + .map(String::from) + .ok_or_else(|| "Browserless response missing 'stop' URL".to_string())?; + + Ok(( + connect_url, + Some(ProviderSession { + provider: "browserless".to_string(), + // Store the stop URL as the session_id for cleanup + session_id: stop_url, + }), + )) +} + async fn connect_browser_use() -> Result<(String, Option), String> { let api_key = env::var("BROWSER_USE_API_KEY") .map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;