diff --git a/README.md b/README.md index 45bdc674..5570db09 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,23 @@ The daemon starts automatically on first command and persists between commands f | Linux x64 | ✅ Native Rust | Node.js | | Windows | - | Node.js | +## Integrations + +### Browserbase + +[Browserbase](https://browserbase.com) provides remote browser infrastructure to make deployment of agentic browsing agents easy. Use it when running the agent-browser CLI in an environment where a local browser isn't feasible. + +To enable Browserbase, set these environment variables: + +```bash +export BROWSERBASE_API_KEY="your-api-key" +export BROWSERBASE_PROJECT_ID="your-project-id" +``` + +When both variables are set, agent-browser automatically connects to a Browserbase session instead of launching a local browser. All commands work identically. + +Get your API key and project ID from the [Browserbase Dashboard](https://browserbase.com/dashboard). + ## License Apache-2.0 diff --git a/cli/Cargo.lock b/cli/Cargo.lock index fe31275f..18194a33 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agent-browser" -version = "0.1.0" +version = "0.4.0" dependencies = [ "libc", "serde", diff --git a/cli/src/commands.rs b/cli/src/commands.rs index a3298895..81e90ef5 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -274,6 +274,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Option { _ => None, }, + // Session commands are handled entirely in main.rs + "session" => None, + _ => None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index bc50d32a..cf1fbb23 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -108,7 +108,7 @@ fn get_port_for_session(session: &str) -> u16 { } #[cfg(unix)] -fn is_daemon_running(session: &str) -> bool { +pub fn is_daemon_running(session: &str) -> bool { let pid_path = get_pid_path(session); if !pid_path.exists() { return false; @@ -124,7 +124,7 @@ fn is_daemon_running(session: &str) -> bool { } #[cfg(windows)] -fn is_daemon_running(session: &str) -> bool { +pub fn is_daemon_running(session: &str) -> bool { let pid_path = get_pid_path(session); if !pid_path.exists() { return false; diff --git a/cli/src/main.rs b/cli/src/main.rs index d715e3f3..01499fbf 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,6 +3,7 @@ mod connection; mod flags; mod install; mod output; +mod session; use serde_json::json; use std::env; @@ -13,6 +14,139 @@ use connection::{ensure_daemon, send_command}; use flags::{clean_args, parse_flags}; use install::run_install; use output::{print_help, print_response}; +use session::{find_local_sessions, is_uuid, kill_local_session, show_local_session_info}; + +/// Try to get cloud sessions from daemon (returns empty vec if daemon not running or API key not set) +fn try_get_cloud_sessions(session: &str, _json: bool) -> Vec { + // Check if daemon is running first + if !connection::is_daemon_running(session) { + return Vec::new(); + } + + let cmd = json!({ + "id": gen_id(), + "action": "bb_session_list" + }); + + match send_command(cmd, session) { + Ok(resp) if resp.success => { + if let Some(data) = &resp.data { + if let Some(sessions) = data.get("sessions") { + if let Some(arr) = sessions.as_array() { + return arr.clone(); + } + } + } + Vec::new() + } + _ => Vec::new(), + } +} + +/// Handle cloud session info query +fn handle_cloud_session_info(id: &str, flags: &flags::Flags) { + if let Err(e) = ensure_daemon(&flags.session, flags.headed) { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + + let cmd = json!({ + "id": gen_id(), + "action": "bb_session_get", + "sessionId": id + }); + + match send_command(cmd, &flags.session) { + Ok(resp) => { + print_response(&resp, flags.json); + if !resp.success { + exit(1); + } + } + Err(e) => { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + } +} + +/// Handle cloud session stop +fn handle_cloud_session_stop(id: &str, flags: &flags::Flags) { + if let Err(e) = ensure_daemon(&flags.session, flags.headed) { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + + let cmd = json!({ + "id": gen_id(), + "action": "bb_session_stop", + "sessionId": id + }); + + match send_command(cmd, &flags.session) { + Ok(resp) => { + print_response(&resp, flags.json); + if !resp.success { + exit(1); + } + } + Err(e) => { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + } +} + +/// Handle cloud session debug +fn handle_cloud_session_debug(id: &str, flags: &flags::Flags) { + if let Err(e) = ensure_daemon(&flags.session, flags.headed) { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + + let cmd = json!({ + "id": gen_id(), + "action": "bb_session_debug", + "sessionId": id + }); + + match send_command(cmd, &flags.session) { + Ok(resp) => { + print_response(&resp, flags.json); + if !resp.success { + exit(1); + } + } + Err(e) => { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + } +} fn main() { let args: Vec = env::args().skip(1).collect(); @@ -31,6 +165,82 @@ fn main() { return; } + // Handle unified session commands + if clean.get(0).map(|s| s.as_str()) == Some("session") { + match clean.get(1).map(|s| s.as_str()) { + Some("list") => { + // Get local sessions first + let local_sessions = find_local_sessions(); + + // Try to get cloud sessions from daemon (non-blocking) + let cloud_sessions = try_get_cloud_sessions(&flags.session, flags.json); + + // Print combined results + output::print_session_list(&local_sessions, &cloud_sessions, flags.json); + return; + } + Some("info") => { + let id = clean.get(2).map(|s| s.as_str()).unwrap_or(&flags.session); + + // If looks like UUID, query cloud; otherwise check local + if is_uuid(id) { + // Query cloud session via daemon + handle_cloud_session_info(id, &flags); + } else { + show_local_session_info(id, flags.json); + } + return; + } + Some("kill") => { + if let Some(id) = clean.get(2) { + // If looks like UUID, stop cloud session; otherwise kill local + if is_uuid(id) { + handle_cloud_session_stop(id, &flags); + } else { + kill_local_session(id, flags.json); + } + } else { + if flags.json { + println!(r#"{{"success":false,"error":"session kill requires a session name or ID"}}"#); + } else { + eprintln!("\x1b[31m✗\x1b[0m session kill requires a session name or ID"); + eprintln!("\x1b[2mUsage: agent-browser session kill \x1b[0m"); + } + exit(1); + } + return; + } + Some("debug") => { + if let Some(id) = clean.get(2) { + handle_cloud_session_debug(id, &flags); + } else { + if flags.json { + println!(r#"{{"success":false,"error":"session debug requires a session ID"}}"#); + } else { + eprintln!("\x1b[31m✗\x1b[0m session debug requires a session ID"); + eprintln!("\x1b[2mUsage: agent-browser session debug \x1b[0m"); + } + exit(1); + } + return; + } + Some(_) => { + if flags.json { + println!(r#"{{"success":false,"error":"Unknown session subcommand"}}"#); + } else { + eprintln!("\x1b[31m✗\x1b[0m Unknown session subcommand"); + eprintln!("\x1b[2mUsage: agent-browser session [list|info|kill|debug]\x1b[0m"); + } + exit(1); + } + None => { + // `session` with no subcommand shows current session + show_local_session_info(&flags.session, flags.json); + return; + } + } + } + let cmd = match parse_command(&clean, &flags) { Some(c) => c, None => { diff --git a/cli/src/output.rs b/cli/src/output.rs index 18b4eba4..6aa5e2dd 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1,4 +1,87 @@ use crate::connection::Response; +use crate::session::LocalSession; +use serde_json::json; + +/// Print unified session list (local + cloud) +pub fn print_session_list( + local_sessions: &[LocalSession], + cloud_sessions: &[serde_json::Value], + json_mode: bool, +) { + if json_mode { + let local_json: Vec<_> = local_sessions + .iter() + .map(|s| { + json!({ + "type": "local", + "name": s.name, + "pid": s.pid, + "running": s.running, + "socket": s.socket_path.to_string_lossy(), + }) + }) + .collect(); + + let cloud_json: Vec<_> = cloud_sessions + .iter() + .map(|s| { + let mut obj = s.clone(); + if let Some(map) = obj.as_object_mut() { + map.insert("type".to_string(), json!("cloud")); + } + obj + }) + .collect(); + + let combined: Vec<_> = local_json.into_iter().chain(cloud_json).collect(); + println!("{}", serde_json::to_string(&combined).unwrap_or_default()); + return; + } + + let has_local = !local_sessions.is_empty(); + let has_cloud = !cloud_sessions.is_empty(); + + if !has_local && !has_cloud { + println!("No active sessions."); + println!("\x1b[2mStart a session with: agent-browser --session open \x1b[0m"); + return; + } + + if has_local { + println!("Local Sessions:"); + for s in local_sessions { + let status = if s.running { + "\x1b[32m●\x1b[0m" + } else { + "\x1b[31m○\x1b[0m" + }; + let state = if s.running { "running" } else { "stopped" }; + println!(" {} {} (PID: {}, {})", status, s.name, s.pid, state); + } + } + + if has_cloud { + if has_local { + println!(); + } + println!("Cloud Sessions (Browserbase):"); + for session in cloud_sessions { + let id = session.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let status = session.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let region = session.get("region").and_then(|v| v.as_str()).unwrap_or("?"); + let status_color = match status { + "RUNNING" => "\x1b[32m", + "COMPLETED" => "\x1b[34m", + "ERROR" | "TIMED_OUT" => "\x1b[31m", + _ => "\x1b[0m", + }; + println!(" {}●\x1b[0m {} [{}] ({})", status_color, id, status, region); + } + } + + println!(); + println!("\x1b[2mUse 'session info ' for details, 'session kill ' to stop\x1b[0m"); +} pub fn print_response(resp: &Response, json_mode: bool) { if json_mode { @@ -140,6 +223,89 @@ pub fn print_response(resp: &Response, json_mode: bool) { println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path); return; } + // Browserbase session list + if let Some(sessions) = data.get("sessions").and_then(|v| v.as_array()) { + if sessions.is_empty() { + println!("No Browserbase sessions found."); + } else { + println!("Browserbase Sessions:"); + for session in sessions { + let id = session.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let status = session.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let region = session.get("region").and_then(|v| v.as_str()).unwrap_or("?"); + let created = session.get("createdAt").and_then(|v| v.as_str()).unwrap_or("?"); + let status_color = match status { + "RUNNING" => "\x1b[32m", + "COMPLETED" => "\x1b[34m", + "ERROR" | "TIMED_OUT" => "\x1b[31m", + _ => "\x1b[0m", + }; + println!(" {}●\x1b[0m {} [{}]", status_color, id, status); + println!(" Region: {}, Created: {}", region, created); + } + } + return; + } + // Browserbase session details + if let Some(session) = data.get("session") { + let id = session.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let status = session.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let region = session.get("region").and_then(|v| v.as_str()).unwrap_or("?"); + let created = session.get("createdAt").and_then(|v| v.as_str()).unwrap_or("?"); + let keep_alive = session.get("keepAlive").and_then(|v| v.as_bool()).unwrap_or(false); + let project = session.get("projectId").and_then(|v| v.as_str()).unwrap_or("?"); + + println!("Session: \x1b[1m{}\x1b[0m", id); + println!(); + let status_icon = match status { + "RUNNING" => "\x1b[32m●\x1b[0m", + "COMPLETED" => "\x1b[34m●\x1b[0m", + _ => "\x1b[31m●\x1b[0m", + }; + println!(" Status: {} {}", status_icon, status); + println!(" Region: {}", region); + println!(" Project: {}", project); + println!(" Created: {}", created); + println!(" Keep Alive: {}", if keep_alive { "yes" } else { "no" }); + + if let Some(connect_url) = session.get("connectUrl").and_then(|v| v.as_str()) { + println!(" Connect: {}", connect_url); + } + return; + } + // Browserbase debug URLs + if let Some(debug) = data.get("debug") { + let debugger_url = debug.get("debuggerUrl").and_then(|v| v.as_str()).unwrap_or("?"); + let fullscreen = debug.get("debuggerFullscreenUrl").and_then(|v| v.as_str()); + let ws_url = debug.get("wsUrl").and_then(|v| v.as_str()); + + println!("Debug URLs:"); + println!(" Debugger: {}", debugger_url); + if let Some(fs) = fullscreen { + println!(" Fullscreen: {}", fs); + } + if let Some(ws) = ws_url { + println!(" WebSocket: {}", ws); + } + + if let Some(pages) = debug.get("pages").and_then(|v| v.as_array()) { + if !pages.is_empty() { + println!("\nPages:"); + for page in pages { + let title = page.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled"); + let url = page.get("url").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" - {} ({})", title, url); + } + } + } + return; + } + // Session stopped + if data.get("stopped").is_some() { + let session_id = data.get("sessionId").and_then(|v| v.as_str()).unwrap_or("session"); + println!("\x1b[32m✓\x1b[0m Session {} stopped", session_id); + return; + } // Default success println!("\x1b[32m✓\x1b[0m Done"); } @@ -215,6 +381,13 @@ Debug: errors [--clear] View page errors highlight Highlight element +Sessions: + session list List all active sessions (local + cloud) + session Show current session + session info Show session details + session kill Stop/kill a session + session debug Get debug URLs (cloud sessions only) + Setup: install Install browser binaries install --with-deps Also install system dependencies (Linux) diff --git a/cli/src/session.rs b/cli/src/session.rs new file mode 100644 index 00000000..ff3920d6 --- /dev/null +++ b/cli/src/session.rs @@ -0,0 +1,249 @@ +use serde_json::json; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::exit; + +/// Check if a string looks like a UUID (Browserbase session ID) +pub fn is_uuid(s: &str) -> bool { + // UUIDs are 36 chars: 8-4-4-4-12 with hyphens + if s.len() != 36 { + return false; + } + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() != 5 { + return false; + } + let expected_lens = [8, 4, 4, 4, 12]; + for (i, part) in parts.iter().enumerate() { + if part.len() != expected_lens[i] { + return false; + } + if !part.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + } + true +} + +/// Get the temp directory for session files +fn get_temp_dir() -> PathBuf { + env::temp_dir() +} + +/// Check if a process is running by PID +#[cfg(unix)] +fn is_process_running(pid: i32) -> bool { + unsafe { libc::kill(pid, 0) == 0 } +} + +#[cfg(windows)] +fn is_process_running(_pid: i32) -> bool { + // On Windows, simplified check - could use tasklist or Windows APIs + // For now, assume running if PID file exists + true +} + +/// Information about a local session +#[derive(Debug)] +pub struct LocalSession { + pub name: String, + pub pid: i32, + pub running: bool, + pub socket_path: PathBuf, + pub socket_exists: bool, +} + +/// Find all local sessions by scanning PID files in temp directory +pub fn find_local_sessions() -> Vec { + let tmp = get_temp_dir(); + let mut sessions = Vec::new(); + + if let Ok(entries) = fs::read_dir(&tmp) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("agent-browser-") && name.ends_with(".pid") { + let session_name = name + .strip_prefix("agent-browser-") + .and_then(|s| s.strip_suffix(".pid")) + .unwrap_or("") + .to_string(); + + if session_name.is_empty() { + continue; + } + + if let Ok(pid_str) = fs::read_to_string(entry.path()) { + if let Ok(pid) = pid_str.trim().parse::() { + let running = is_process_running(pid); + let socket_path = tmp.join(format!("agent-browser-{}.sock", session_name)); + let socket_exists = socket_path.exists(); + + sessions.push(LocalSession { + name: session_name, + pid, + running, + socket_path, + socket_exists, + }); + } + } + } + } + } + + // Sort by name for consistent output + sessions.sort_by(|a, b| a.name.cmp(&b.name)); + sessions +} + +/// Show detailed info about a specific local session +pub fn show_local_session_info(name: &str, json_mode: bool) { + let tmp = get_temp_dir(); + let pid_path = tmp.join(format!("agent-browser-{}.pid", name)); + let socket_path = tmp.join(format!("agent-browser-{}.sock", name)); + + if !pid_path.exists() { + if json_mode { + println!( + "{}", + json!({ "success": false, "error": format!("Session '{}' not found", name) }) + ); + } else { + eprintln!("\x1b[31m✗\x1b[0m Session '{}' not found", name); + } + exit(1); + } + + let pid = fs::read_to_string(&pid_path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + + let running = is_process_running(pid); + let socket_exists = socket_path.exists(); + + if json_mode { + println!( + "{}", + json!({ + "success": true, + "session": { + "name": name, + "pid": pid, + "running": running, + "socket": socket_path.to_string_lossy(), + "socketExists": socket_exists, + "pidFile": pid_path.to_string_lossy() + } + }) + ); + } else { + println!("Session: \x1b[1m{}\x1b[0m", name); + println!(); + let status_icon = if running { + "\x1b[32m●\x1b[0m" + } else { + "\x1b[31m○\x1b[0m" + }; + let status_text = if running { "running" } else { "stopped" }; + println!(" Status: {} {}", status_icon, status_text); + println!(" PID: {}", pid); + println!(" PID File: {}", pid_path.display()); + println!(" Socket: {}", socket_path.display()); + println!( + " Socket OK: {}", + if socket_exists { "yes" } else { "no" } + ); + } +} + +/// Kill a local daemon session +pub fn kill_local_session(name: &str, json_mode: bool) { + let tmp = get_temp_dir(); + let pid_path = tmp.join(format!("agent-browser-{}.pid", name)); + let socket_path = tmp.join(format!("agent-browser-{}.sock", name)); + + if !pid_path.exists() { + if json_mode { + println!( + "{}", + json!({ "success": false, "error": format!("Session '{}' not found", name) }) + ); + } else { + eprintln!("\x1b[31m✗\x1b[0m Session '{}' not found", name); + } + exit(1); + } + + let pid = match fs::read_to_string(&pid_path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + Some(p) => p, + None => { + if json_mode { + println!( + "{}", + json!({ "success": false, "error": "Failed to read PID file" }) + ); + } else { + eprintln!("\x1b[31m✗\x1b[0m Failed to read PID file"); + } + exit(1); + } + }; + + // Send SIGTERM to the process + #[cfg(unix)] + { + let result = unsafe { libc::kill(pid, libc::SIGTERM) }; + if result != 0 && is_process_running(pid) { + if json_mode { + println!( + "{}", + json!({ "success": false, "error": format!("Failed to kill process {}", pid) }) + ); + } else { + eprintln!("\x1b[31m✗\x1b[0m Failed to kill process {}", pid); + } + exit(1); + } + } + + #[cfg(windows)] + { + // On Windows, use taskkill + let status = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .status(); + + if status.is_err() || !status.unwrap().success() { + if json_mode { + println!( + "{}", + json!({ "success": false, "error": format!("Failed to kill process {}", pid) }) + ); + } else { + eprintln!("\x1b[31m✗\x1b[0m Failed to kill process {}", pid); + } + exit(1); + } + } + + // Clean up files + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(&socket_path); + + if json_mode { + println!( + "{}", + json!({ "success": true, "killed": name, "pid": pid }) + ); + } else { + println!( + "\x1b[32m✓\x1b[0m Killed session '{}' (PID: {})", + name, pid + ); + } +} diff --git a/package.json b/package.json index 25c2d868..5fd65fdc 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "homepage": "https://github.com/vercel-labs/agent-browser#readme", "dependencies": { + "@browserbasehq/sdk": "^2.0.0", "playwright-core": "^1.57.0", "zod": "^3.22.4" }, diff --git a/src/actions.ts b/src/actions.ts index 0b1fe8c1..1c24c0cc 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,4 +1,5 @@ import type { Page, Frame } from 'playwright-core'; +import Browserbase from '@browserbasehq/sdk'; import type { BrowserManager } from './browser.js'; import type { Command, @@ -102,6 +103,10 @@ import type { TabNewData, TabSwitchData, TabCloseData, + BbSessionListCommand, + BbSessionGetCommand, + BbSessionDebugCommand, + BbSessionStopCommand, } from './types.js'; import { successResponse, errorResponse } from './protocol.js'; @@ -345,6 +350,14 @@ export async function executeCommand(command: Command, browser: BrowserManager): return await handleWaitForDownload(command, browser); case 'responsebody': return await handleResponseBody(command, browser); + case 'bb_session_list': + return await handleBbSessionList(command); + case 'bb_session_get': + return await handleBbSessionGet(command); + case 'bb_session_debug': + return await handleBbSessionDebug(command); + case 'bb_session_stop': + return await handleBbSessionStop(command); default: { // TypeScript narrows to never here, but we handle it for safety const unknownCommand = command as { id: string; action: string }; @@ -1668,3 +1681,81 @@ async function handleResponseBody( body: parsed, }); } + +// Browserbase Session Management Handlers + +function getBrowserbaseClient(): Browserbase { + const apiKey = process.env.BROWSERBASE_API_KEY; + if (!apiKey) { + throw new Error('BROWSERBASE_API_KEY environment variable is not set'); + } + return new Browserbase({ apiKey }); +} + +async function handleBbSessionList(command: BbSessionListCommand): Promise { + const bb = getBrowserbaseClient(); + const sessions = await bb.sessions.list({ + status: command.status, + }); + + // Convert to plain objects for JSON serialization + const sessionList = []; + for await (const session of sessions) { + sessionList.push({ + id: session.id, + status: session.status, + createdAt: session.createdAt, + startedAt: session.startedAt, + endedAt: session.endedAt, + projectId: session.projectId, + region: session.region, + }); + } + + return successResponse(command.id, { sessions: sessionList }); +} + +async function handleBbSessionGet(command: BbSessionGetCommand): Promise { + const bb = getBrowserbaseClient(); + const session = await bb.sessions.retrieve(command.sessionId); + + return successResponse(command.id, { + session: { + id: session.id, + status: session.status, + createdAt: session.createdAt, + startedAt: session.startedAt, + endedAt: session.endedAt, + projectId: session.projectId, + region: session.region, + proxyBytes: session.proxyBytes, + avgCpuUsage: session.avgCpuUsage, + memoryUsage: session.memoryUsage, + }, + }); +} + +async function handleBbSessionDebug(command: BbSessionDebugCommand): Promise { + const bb = getBrowserbaseClient(); + const debug = await bb.sessions.debug(command.sessionId); + + return successResponse(command.id, { + debuggerFullscreenUrl: debug.debuggerFullscreenUrl, + debuggerUrl: debug.debuggerUrl, + wsUrl: debug.wsUrl, + }); +} + +async function handleBbSessionStop(command: BbSessionStopCommand): Promise { + const bb = getBrowserbaseClient(); + const projectId = process.env.BROWSERBASE_PROJECT_ID; + if (!projectId) { + throw new Error('BROWSERBASE_PROJECT_ID environment variable is not set'); + } + await bb.sessions.update(command.sessionId, { + projectId, + status: 'REQUEST_RELEASE', + }); + + return successResponse(command.id, { stopped: true, sessionId: command.sessionId }); +} diff --git a/src/browser.ts b/src/browser.ts index 03b17e41..4d9bdb9d 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -12,6 +12,7 @@ import { type Route, type Locator, } from 'playwright-core'; +import Browserbase from '@browserbasehq/sdk'; import type { LaunchCommand } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; @@ -494,6 +495,36 @@ export class BrowserManager { return this.browser; } + /** + * Connect to Browserbase remote browser via CDP. + * Returns true if connected, false if credentials not available. + */ + private async connectToBrowserbase(): Promise { + const browserbaseApiKey = process.env.BROWSERBASE_API_KEY; + const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID; + + if (!browserbaseApiKey || !browserbaseProjectId) { + return false; + } + + const bb = new Browserbase({ apiKey: browserbaseApiKey }); + const session = await bb.sessions.create({ projectId: browserbaseProjectId }); + this.browser = await chromium.connectOverCDP(session.connectUrl); + + // Get default context to ensure sessions are recorded + const context = this.browser.contexts()[0]; + context.setDefaultTimeout(10000); + this.contexts.push(context); + + // Get existing page or create new one + const page = context.pages()[0] ?? (await context.newPage()); + this.pages.push(page); + this.activePageIndex = 0; + this.setupPageTracking(page); + + return true; + } + /** * Launch the browser with the specified options * If already launched, this is a no-op (browser stays open) @@ -504,6 +535,11 @@ export class BrowserManager { return; } + // Try connecting to Browserbase if credentials are available + if (await this.connectToBrowserbase()) { + return; + } + // Select browser type const browserType = options.browser ?? 'chromium'; const launcher = diff --git a/src/daemon.ts b/src/daemon.ts index 27c241ae..6987e23b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -150,11 +150,11 @@ export async function startDaemon(): Promise { continue; } - // Auto-launch browser if not already launched and this isn't a launch command + // Auto-launch browser if not already launched and this isn't a launch/close/session command + const noLaunchActions = ['launch', 'close', 'bb_session_list', 'bb_session_get', 'bb_session_debug', 'bb_session_stop']; if ( !browser.isLaunched() && - parseResult.command.action !== 'launch' && - parseResult.command.action !== 'close' + !noLaunchActions.includes(parseResult.command.action) ) { await browser.launch({ id: 'auto', action: 'launch', headless: true }); } diff --git a/src/protocol.ts b/src/protocol.ts index a2be79f1..cfc35b14 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -678,6 +678,27 @@ const windowNewSchema = baseCommandSchema.extend({ .optional(), }); +// Browserbase session schemas +const bbSessionListSchema = baseCommandSchema.extend({ + action: z.literal('bb_session_list'), + status: z.enum(['RUNNING', 'ERROR', 'TIMED_OUT', 'COMPLETED']).optional(), +}); + +const bbSessionGetSchema = baseCommandSchema.extend({ + action: z.literal('bb_session_get'), + sessionId: z.string().min(1), +}); + +const bbSessionDebugSchema = baseCommandSchema.extend({ + action: z.literal('bb_session_debug'), + sessionId: z.string().min(1), +}); + +const bbSessionStopSchema = baseCommandSchema.extend({ + action: z.literal('bb_session_stop'), + sessionId: z.string().min(1), +}); + // Union schema for all commands const commandSchema = z.discriminatedUnion('action', [ launchSchema, @@ -794,6 +815,10 @@ const commandSchema = z.discriminatedUnion('action', [ multiSelectSchema, waitForDownloadSchema, responseBodySchema, + bbSessionListSchema, + bbSessionGetSchema, + bbSessionDebugSchema, + bbSessionStopSchema, ]); // Parse result type diff --git a/src/types.ts b/src/types.ts index 64f1a661..8700f883 100644 --- a/src/types.ts +++ b/src/types.ts @@ -722,6 +722,27 @@ export interface WindowNewCommand extends BaseCommand { viewport?: { width: number; height: number }; } +// Browserbase session commands +export interface BbSessionListCommand extends BaseCommand { + action: 'bb_session_list'; + status?: 'RUNNING' | 'ERROR' | 'TIMED_OUT' | 'COMPLETED'; +} + +export interface BbSessionGetCommand extends BaseCommand { + action: 'bb_session_get'; + sessionId: string; +} + +export interface BbSessionDebugCommand extends BaseCommand { + action: 'bb_session_debug'; + sessionId: string; +} + +export interface BbSessionStopCommand extends BaseCommand { + action: 'bb_session_stop'; + sessionId: string; +} + // Union of all command types export type Command = | LaunchCommand @@ -837,7 +858,11 @@ export type Command = | InsertTextCommand | MultiSelectCommand | WaitForDownloadCommand - | ResponseBodyCommand; + | ResponseBodyCommand + | BbSessionListCommand + | BbSessionGetCommand + | BbSessionDebugCommand + | BbSessionStopCommand; // Response types export interface SuccessResponse {