From c4139fa38925fe752ca2d5315480f63a718ce421 Mon Sep 17 00:00:00 2001 From: Aitor <44302869+reformedot@users.noreply.github.com> Date: Thu, 22 Jan 2026 01:01:19 +0100 Subject: [PATCH] feat: add Browser Use cloud browser as available provider (#138) * feat: add Browser Use cloud browser integration * feat: enhance Browser Use integration with provider flag support - Updated README to reflect new usage instructions for enabling Browser Use with the `-p` flag. - Modified CLI to parse and handle the `-p` flag for specifying the provider. - Implemented logic in the main application to launch with the specified cloud provider. - Adjusted BrowserManager to connect to Browser Use based on the provider flag or environment variable. - Updated types and protocol schemas to include provider information. * feat: add validation for mutually exclusive CLI options - Implemented checks to prevent the use of both --cdp and --provider flags simultaneously. - Added validation to ensure --extension cannot be used with the --provider flag. - Enhanced error handling to provide clear feedback in both JSON and console output formats. --- README.md | 23 ++++++++++ cli/src/flags.rs | 10 +++- cli/src/main.rs | 49 +++++++++++++++++++- src/browser.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++-- src/protocol.ts | 1 + src/types.ts | 1 + 6 files changed, 194 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 668f2c3..e97be58 100644 --- a/README.md +++ b/README.md @@ -674,6 +674,29 @@ When both variables are set, agent-browser automatically connects to a Browserba Get your API key and project ID from the [Browserbase Dashboard](https://browserbase.com/overview). +### Browser Use + +[Browser Use](https://browser-use.com) provides cloud browser infrastructure for AI agents. Use it when running agent-browser in environments where a local browser isn't available (serverless, CI/CD, etc.). + +To enable Browser Use, use the `-p` flag: + +```bash +export BROWSER_USE_API_KEY="your-api-key" +agent-browser -p browseruse open https://example.com +``` + +Or use environment variables for CI/scripts: + +```bash +export AGENT_BROWSER_PROVIDER=browseruse +export BROWSER_USE_API_KEY="your-api-key" +agent-browser open https://example.com +``` + +When enabled, agent-browser connects to a Browser Use cloud session instead of launching a local browser. All commands work identically. + +Get your API key from the [Browser Use Cloud Dashboard](https://cloud.browser-use.com/settings?tab=api-keys). Free credits are available to get started, with pay-as-you-go pricing after. + ## License Apache-2.0 diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 52692db..fd8b610 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -11,6 +11,7 @@ pub struct Flags { pub cdp: Option, pub extensions: Vec, pub proxy: Option, + pub provider: Option, } pub fn parse_flags(args: &[String]) -> Flags { @@ -30,6 +31,7 @@ pub fn parse_flags(args: &[String]) -> Flags { cdp: None, extensions: extensions_env, proxy: None, + provider: env::var("AGENT_BROWSER_PROVIDER").ok(), }; let mut i = 0; @@ -75,6 +77,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "-p" | "--provider" => { + if let Some(p) = args.get(i + 1) { + flags.provider = Some(p.clone()); + i += 1; + } + } _ => {} } i += 1; @@ -89,7 +97,7 @@ pub fn clean_args(args: &[String]) -> Vec { // Global flags that should be stripped from command args const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; // Global flags that take a value (need to skip the next arg too) - const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy"]; + const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy", "-p", "--provider"]; for arg in args.iter() { if skip_next { diff --git a/cli/src/main.rs b/cli/src/main.rs index 0964c7e..53a5439 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -216,6 +216,27 @@ fn main() { } } + // Validate mutually exclusive options + if flags.cdp.is_some() && flags.provider.is_some() { + let msg = "Cannot use --cdp and -p/--provider together"; + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + + if flags.provider.is_some() && !flags.extensions.is_empty() { + let msg = "Cannot use --extension with -p/--provider (extensions require local browser)"; + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + // Connect via CDP if --cdp flag is set if let Some(ref port) = flags.cdp { let cdp_port: u16 = match port.parse::() { @@ -271,8 +292,32 @@ fn main() { } } - // Launch headed browser or proxy if flags are set (without CDP) - if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() { + // Launch with cloud provider if -p flag is set + if let Some(ref provider) = flags.provider { + let launch_cmd = json!({ + "id": gen_id(), + "action": "launch", + "provider": provider + }); + + let err = match send_command(launch_cmd, &flags.session) { + Ok(resp) if resp.success => None, + Ok(resp) => Some(resp.error.unwrap_or_else(|| "Provider connection failed".to_string())), + Err(e) => Some(e.to_string()), + }; + + if let Some(msg) = err { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + } + + // Launch headed browser or proxy if flags are set (without CDP or provider) + if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() && flags.provider.is_none() { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", diff --git a/src/browser.ts b/src/browser.ts index c38ef75..186665f 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -72,6 +72,8 @@ export class BrowserManager { private isPersistentContext: boolean = false; private browserbaseSessionId: string | null = null; private browserbaseApiKey: string | null = null; + private browserUseSessionId: string | null = null; + private browserUseApiKey: string | null = null; private contexts: BrowserContext[] = []; private pages: Page[] = []; private activePageIndex: number = 0; @@ -656,6 +658,24 @@ export class BrowserManager { }); } + /** + * Close a Browser Use session via API + */ + private async closeBrowserUseSession(sessionId: string, apiKey: string): Promise { + const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Browser-Use-API-Key': apiKey, + }, + body: JSON.stringify({ action: 'stop' }), + }); + + if (!response.ok) { + throw new Error(`Failed to close Browser Use session: ${response.statusText}`); + } + } + /** * Connect to Browserbase remote browser via CDP. * Returns true if connected, false if credentials not available. @@ -683,8 +703,8 @@ export class BrowserManager { throw new Error(`Failed to create Browserbase session: ${response.statusText}`); } - const session = await response.json() as { id: string; connectUrl: string }; - + const session = (await response.json()) as { id: string; connectUrl: string }; + const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => { throw new Error('Failed to connect to Browserbase session via CDP'); }); @@ -717,6 +737,79 @@ export class BrowserManager { } } + /** + * Connect to Browser Use remote browser via CDP. + * Requires BROWSER_USE_API_KEY environment variable. + */ + private async connectToBrowserUse(): Promise { + const browserUseApiKey = process.env.BROWSER_USE_API_KEY; + if (!browserUseApiKey) { + throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider'); + } + + const response = await fetch('https://api.browser-use.com/api/v2/browsers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Browser-Use-API-Key': browserUseApiKey, + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error(`Failed to create Browser Use session: ${response.statusText}`); + } + + let session: { id: string; cdpUrl: string }; + try { + session = (await response.json()) as { id: string; cdpUrl: string }; + } catch (error) { + throw new Error( + `Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!session.id || !session.cdpUrl) { + throw new Error( + `Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}` + ); + } + + const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => { + throw new Error('Failed to connect to Browser Use session via CDP'); + }); + + try { + const contexts = browser.contexts(); + let context: BrowserContext; + let page: Page; + + if (contexts.length === 0) { + context = await browser.newContext(); + page = await context.newPage(); + } else { + context = contexts[0]; + const pages = context.pages(); + page = pages[0] ?? (await context.newPage()); + } + + this.browserUseSessionId = session.id; + this.browserUseApiKey = browserUseApiKey; + this.browser = browser; + context.setDefaultTimeout(60000); + this.contexts.push(context); + this.pages.push(page); + this.activePageIndex = 0; + this.setupPageTracking(page); + this.setupContextTracking(context); + } catch (error) { + await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => { + console.error('Failed to close Browser Use session during cleanup:', sessionError); + }); + throw error; + } + } + /** * Launch the browser with the specified options * If already launched, this is a no-op (browser stays open) @@ -744,12 +837,19 @@ export class BrowserManager { return; } - // Try connecting to Browserbase if credentials are available + // Try connecting to cloud browser providers if configured + // Browserbase: auto-connects when BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are set if (await this.connectToBrowserbase()) { return; } - // Select browser type + // Browser Use: requires explicit opt-in via -p browseruse flag or AGENT_BROWSER_PROVIDER=browseruse + const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; + if (provider === 'browseruse') { + await this.connectToBrowserUse(); + return; + } + const browserType = options.browser ?? 'chromium'; if (hasExtensions && browserType !== 'chromium') { throw new Error('Extensions are only supported in Chromium'); @@ -1447,6 +1547,13 @@ export class BrowserManager { } ); this.browser = null; + } else if (this.browserUseSessionId && this.browserUseApiKey) { + await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch( + (error) => { + console.error('Failed to close Browser Use session:', error); + } + ); + this.browser = null; } else if (this.cdpPort !== null) { // CDP: only disconnect, don't close external app's pages if (this.browser) { @@ -1472,6 +1579,8 @@ export class BrowserManager { this.cdpPort = null; this.browserbaseSessionId = null; this.browserbaseApiKey = null; + this.browserUseSessionId = null; + this.browserUseApiKey = null; this.isPersistentContext = false; this.activePageIndex = 0; this.refMap = {}; diff --git a/src/protocol.ts b/src/protocol.ts index e255f8c..dd9819a 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -30,6 +30,7 @@ const launchSchema = baseCommandSchema.extend({ password: z.string().optional(), }) .optional(), + provider: z.string().optional(), }); const navigateSchema = baseCommandSchema.extend({ diff --git a/src/types.ts b/src/types.ts index 2eb09a3..6d22b9d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ export interface LaunchCommand extends BaseCommand { username?: string; password?: string; }; + provider?: string; } export interface NavigateCommand extends BaseCommand {