diff --git a/server/packages/sandbox-agent/src/browser_runtime.rs b/server/packages/sandbox-agent/src/browser_runtime.rs new file mode 100644 index 0000000..cec6a88 --- /dev/null +++ b/server/packages/sandbox-agent/src/browser_runtime.rs @@ -0,0 +1,982 @@ +use std::collections::HashMap; +use std::collections::VecDeque; +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Mutex; + +use crate::browser_cdp::CdpClient; +use crate::browser_errors::BrowserProblem; +use crate::browser_install::{ + browser_platform_support_message, detect_missing_browser_dependencies, +}; +use crate::browser_types::{ + BrowserConsoleMessage, BrowserNetworkRequest, BrowserStartRequest, BrowserState, + BrowserStatusResponse, +}; +use crate::desktop_install::find_binary; +use crate::desktop_runtime::DesktopRuntime; +use crate::desktop_streaming::DesktopStreamingManager; +use crate::desktop_types::{DesktopErrorInfo, DesktopProcessInfo, DesktopResolution}; +use crate::process_runtime::{ + ProcessOwner, ProcessRuntime, ProcessStartSpec, ProcessStatus, RestartPolicy, +}; + +const DEFAULT_WIDTH: u32 = 1440; +const DEFAULT_HEIGHT: u32 = 900; +const DEFAULT_DPI: u32 = 96; +const DEFAULT_DISPLAY_NUM: i32 = 98; +const MAX_DISPLAY_PROBE: i32 = 10; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const CDP_POLL_TIMEOUT: Duration = Duration::from_secs(15); +const CDP_PORT: u16 = 9222; +const MAX_CONSOLE_MESSAGES: usize = 1000; +const MAX_NETWORK_REQUESTS: usize = 1000; + +#[derive(Debug, Clone)] +pub struct BrowserRuntime { + config: BrowserRuntimeConfig, + process_runtime: Arc, + desktop_runtime: Arc, + streaming_manager: DesktopStreamingManager, + inner: Arc>, +} + +#[derive(Debug, Clone)] +pub struct BrowserRuntimeConfig { + state_dir: PathBuf, + display_num: i32, + assume_linux_for_tests: bool, +} + +impl Default for BrowserRuntimeConfig { + fn default() -> Self { + Self { + state_dir: default_state_dir(), + display_num: DEFAULT_DISPLAY_NUM, + assume_linux_for_tests: false, + } + } +} + +struct BrowserRuntimeStateData { + state: BrowserState, + display_num: i32, + display: Option, + resolution: Option, + started_at: Option, + last_error: Option, + missing_dependencies: Vec, + install_command: Option, + runtime_log_path: PathBuf, + environment: HashMap, + xvfb: Option, + chromium: Option, + cdp_client: Option, + context_id: Option, + streaming_config: Option, + recording_fps: Option, + console_messages: VecDeque, + network_requests: VecDeque, +} + +impl std::fmt::Debug for BrowserRuntimeStateData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BrowserRuntimeStateData") + .field("state", &self.state) + .field("display", &self.display) + .field("resolution", &self.resolution) + .field("started_at", &self.started_at) + .finish_non_exhaustive() + } +} + +#[derive(Debug)] +struct ManagedBrowserProcess { + name: &'static str, + process_id: String, + pid: Option, + running: bool, +} + +impl BrowserRuntime { + pub fn new(process_runtime: Arc, desktop_runtime: Arc) -> Self { + Self::with_config( + process_runtime, + desktop_runtime, + BrowserRuntimeConfig::default(), + ) + } + + pub fn with_config( + process_runtime: Arc, + desktop_runtime: Arc, + config: BrowserRuntimeConfig, + ) -> Self { + let runtime_log_path = config.state_dir.join("browser-runtime.log"); + Self { + streaming_manager: DesktopStreamingManager::new(process_runtime.clone()), + process_runtime, + desktop_runtime, + inner: Arc::new(Mutex::new(BrowserRuntimeStateData { + state: BrowserState::Inactive, + display_num: config.display_num, + display: None, + resolution: None, + started_at: None, + last_error: None, + missing_dependencies: Vec::new(), + install_command: None, + runtime_log_path, + environment: HashMap::new(), + xvfb: None, + chromium: None, + cdp_client: None, + context_id: None, + streaming_config: None, + recording_fps: None, + console_messages: VecDeque::new(), + network_requests: VecDeque::new(), + })), + config, + } + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + pub async fn status(&self) -> BrowserStatusResponse { + let mut state = self.inner.lock().await; + self.refresh_status_locked(&mut state).await; + let mut response = self.snapshot_locked(&state); + drop(state); + self.append_neko_process(&mut response).await; + response + } + + pub async fn start( + &self, + request: BrowserStartRequest, + ) -> Result { + // Check mutual exclusivity with desktop runtime + let desktop_status = self.desktop_runtime.status().await; + if desktop_status.state == crate::desktop_types::DesktopState::Active { + return Err(BrowserProblem::desktop_conflict()); + } + + let mut state = self.inner.lock().await; + + if !self.platform_supported() { + let problem = BrowserProblem::start_failed(browser_platform_support_message()); + self.record_problem_locked(&mut state, &problem); + state.state = BrowserState::Failed; + return Err(problem); + } + + if matches!(state.state, BrowserState::Starting | BrowserState::Stopping) { + return Err(BrowserProblem::start_failed( + "Browser runtime is busy transitioning state", + )); + } + + self.refresh_status_locked(&mut state).await; + if state.state == BrowserState::Active { + let mut response = self.snapshot_locked(&state); + drop(state); + self.append_neko_process(&mut response).await; + return Ok(response); + } + + if !state.missing_dependencies.is_empty() { + return Err(BrowserProblem::install_required(format!( + "Missing browser dependencies: {}. Run: sandbox-agent install browser --yes", + state.missing_dependencies.join(", ") + ))); + } + + self.ensure_state_dir() + .map_err(|err| BrowserProblem::start_failed(err))?; + self.write_runtime_log_locked(&state, "starting browser runtime"); + + let width = request.width.unwrap_or(DEFAULT_WIDTH); + let height = request.height.unwrap_or(DEFAULT_HEIGHT); + let dpi = request.dpi.unwrap_or(DEFAULT_DPI); + if width == 0 || height == 0 { + return Err(BrowserProblem::start_failed( + "Browser width and height must be greater than 0", + )); + } + + let headless = request.headless.unwrap_or(false); + + // Store streaming/recording config + state.streaming_config = if request.stream_video_codec.is_some() + || request.stream_audio_codec.is_some() + || request.stream_frame_rate.is_some() + || request.webrtc_port_range.is_some() + { + Some(crate::desktop_streaming::StreamingConfig { + video_codec: request + .stream_video_codec + .unwrap_or_else(|| "vp8".to_string()), + audio_codec: request + .stream_audio_codec + .unwrap_or_else(|| "opus".to_string()), + frame_rate: request.stream_frame_rate.unwrap_or(30).clamp(1, 60), + webrtc_port_range: request + .webrtc_port_range + .unwrap_or_else(|| "59050-59070".to_string()), + }) + } else { + None + }; + state.recording_fps = request.recording_fps.map(|fps| fps.clamp(1, 60)); + state.context_id = request.context_id.clone(); + + // Choose display and set up environment + let display_num = if headless { + // Headless doesn't need Xvfb but we still pick a display_num for consistency + self.config.display_num + } else { + self.choose_display_num()? + }; + let display = format!(":{display_num}"); + let resolution = DesktopResolution { + width, + height, + dpi: Some(dpi), + }; + let environment = self.base_environment(&display)?; + + state.state = BrowserState::Starting; + state.display_num = display_num; + state.display = Some(display.clone()); + state.resolution = Some(resolution.clone()); + state.started_at = None; + state.last_error = None; + state.environment = environment; + state.install_command = None; + state.console_messages.clear(); + state.network_requests.clear(); + + // Start Xvfb (unless headless) + if !headless { + if let Err(problem) = self.start_xvfb_locked(&mut state, &resolution).await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + if let Err(problem) = self.wait_for_socket(display_num).await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + } + + // Start Chromium + if let Err(problem) = self + .start_chromium_locked(&mut state, &resolution, headless, request.url.as_deref()) + .await + { + return Err(self.fail_start_locked(&mut state, problem).await); + } + + // Wait for CDP to become ready + if let Err(problem) = self.wait_for_cdp().await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + + // Connect CDP client + match CdpClient::connect().await { + Ok(client) => { + state.cdp_client = Some(client); + } + Err(problem) => { + return Err(self.fail_start_locked(&mut state, problem).await); + } + } + + // Optionally start Neko for streaming + if !headless { + if let Some(streaming_config) = state.streaming_config.clone() { + let display_ref = state.display.clone().unwrap_or_default(); + let resolution_ref = state.resolution.clone().unwrap_or(DesktopResolution { + width, + height, + dpi: Some(dpi), + }); + let env_ref = state.environment.clone(); + drop(state); + let _ = self + .streaming_manager + .start( + &display_ref, + resolution_ref, + &env_ref, + Some(streaming_config), + None, + ) + .await; + state = self.inner.lock().await; + } + } + + state.state = BrowserState::Active; + state.started_at = Some(chrono::Utc::now().to_rfc3339()); + state.last_error = None; + self.write_runtime_log_locked( + &state, + &format!( + "browser runtime active on {} ({}x{}, dpi {})", + display, width, height, dpi + ), + ); + + let mut response = self.snapshot_locked(&state); + drop(state); + self.append_neko_process(&mut response).await; + Ok(response) + } + + pub async fn stop(&self) -> Result { + let mut state = self.inner.lock().await; + if matches!(state.state, BrowserState::Starting | BrowserState::Stopping) { + return Err(BrowserProblem::start_failed( + "Browser runtime is busy transitioning state", + )); + } + + state.state = BrowserState::Stopping; + self.write_runtime_log_locked(&state, "stopping browser runtime"); + + // Close CDP client + if let Some(cdp_client) = state.cdp_client.take() { + cdp_client.close().await; + } + + // Stop streaming + let _ = self.streaming_manager.stop().await; + + // Stop Chromium + self.stop_chromium_locked(&mut state).await; + + // Stop Xvfb + self.stop_xvfb_locked(&mut state).await; + + state.state = BrowserState::Inactive; + state.display = None; + state.resolution = None; + state.started_at = None; + state.last_error = None; + state.context_id = None; + state.missing_dependencies = self.detect_missing_dependencies(); + state.install_command = self.install_command_for(&state.missing_dependencies); + state.environment.clear(); + state.streaming_config = None; + state.recording_fps = None; + state.console_messages.clear(); + state.network_requests.clear(); + + let mut response = self.snapshot_locked(&state); + drop(state); + self.append_neko_process(&mut response).await; + Ok(response) + } + + pub async fn shutdown(&self) { + let _ = self.stop().await; + } + + /// Get a reference to the CDP client, if connected. + pub async fn cdp_client(&self) -> Result { + let state = self.inner.lock().await; + if state.state != BrowserState::Active { + return Err(BrowserProblem::not_active()); + } + // We cannot return a reference out of the Mutex, so we need to use + // the send method directly. For now, return an error if not connected. + // Callers should use `with_cdp` instead. + Err(BrowserProblem::cdp_error( + "Use with_cdp() to execute CDP commands", + )) + } + + /// Execute a closure with the CDP client while holding the state lock. + pub async fn with_cdp(&self, f: F) -> Result + where + F: FnOnce(&CdpClient) -> Fut, + Fut: std::future::Future>, + { + let state = self.inner.lock().await; + if state.state != BrowserState::Active { + return Err(BrowserProblem::not_active()); + } + let cdp = state + .cdp_client + .as_ref() + .ok_or_else(|| BrowserProblem::cdp_error("CDP client is not connected"))?; + f(cdp).await + } + + /// Get the streaming manager for WebRTC signaling. + pub fn streaming_manager(&self) -> &DesktopStreamingManager { + &self.streaming_manager + } + + /// Push a console message into the ring buffer. + pub async fn push_console_message(&self, message: BrowserConsoleMessage) { + let mut state = self.inner.lock().await; + if state.console_messages.len() >= MAX_CONSOLE_MESSAGES { + state.console_messages.pop_front(); + } + state.console_messages.push_back(message); + } + + /// Push a network request into the ring buffer. + pub async fn push_network_request(&self, request: BrowserNetworkRequest) { + let mut state = self.inner.lock().await; + if state.network_requests.len() >= MAX_NETWORK_REQUESTS { + state.network_requests.pop_front(); + } + state.network_requests.push_back(request); + } + + /// Get console messages, optionally filtered by level. + pub async fn console_messages( + &self, + level: Option<&str>, + limit: Option, + ) -> Vec { + let state = self.inner.lock().await; + let limit = limit.unwrap_or(100) as usize; + state + .console_messages + .iter() + .filter(|msg| level.map_or(true, |l| msg.level == l)) + .rev() + .take(limit) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect() + } + + /// Get network requests, optionally filtered by URL pattern. + pub async fn network_requests( + &self, + url_pattern: Option<&str>, + limit: Option, + ) -> Vec { + let state = self.inner.lock().await; + let limit = limit.unwrap_or(100) as usize; + state + .network_requests + .iter() + .filter(|req| url_pattern.map_or(true, |pattern| req.url.contains(pattern))) + .rev() + .take(limit) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect() + } + + // ----------------------------------------------------------------------- + // Internal: state management + // ----------------------------------------------------------------------- + + async fn refresh_status_locked(&self, state: &mut BrowserRuntimeStateData) { + let missing_dependencies = if self.platform_supported() { + self.detect_missing_dependencies() + } else { + Vec::new() + }; + state.missing_dependencies = missing_dependencies.clone(); + state.install_command = self.install_command_for(&missing_dependencies); + + if !self.platform_supported() { + state.state = BrowserState::Failed; + state.last_error = Some( + BrowserProblem::start_failed(browser_platform_support_message()).to_error_info(), + ); + return; + } + + if !missing_dependencies.is_empty() { + state.state = BrowserState::InstallRequired; + state.last_error = Some( + BrowserProblem::install_required(format!( + "Missing: {}", + missing_dependencies.join(", ") + )) + .to_error_info(), + ); + return; + } + + if matches!( + state.state, + BrowserState::Inactive | BrowserState::Starting | BrowserState::Stopping + ) { + if state.state == BrowserState::Inactive { + state.last_error = None; + } + return; + } + + if state.state == BrowserState::Failed + && state.display.is_none() + && state.xvfb.is_none() + && state.chromium.is_none() + { + return; + } + + // Check Xvfb is running (if we started one) + if let Some(ref xvfb) = state.xvfb { + if let Ok(snapshot) = self.process_runtime.snapshot(&xvfb.process_id).await { + if snapshot.status != ProcessStatus::Running { + let problem = BrowserProblem::start_failed("Xvfb process exited unexpectedly"); + self.record_problem_locked(state, &problem); + state.state = BrowserState::Failed; + return; + } + } + } + + // Check Chromium is running + if let Some(ref chromium) = state.chromium { + if let Ok(snapshot) = self.process_runtime.snapshot(&chromium.process_id).await { + if snapshot.status != ProcessStatus::Running { + let problem = + BrowserProblem::start_failed("Chromium process exited unexpectedly"); + self.record_problem_locked(state, &problem); + state.state = BrowserState::Failed; + return; + } + } + } + } + + fn snapshot_locked(&self, state: &BrowserRuntimeStateData) -> BrowserStatusResponse { + BrowserStatusResponse { + state: state.state, + display: state.display.clone(), + resolution: state.resolution.clone(), + started_at: state.started_at.clone(), + cdp_url: if state.state == BrowserState::Active { + Some(format!("ws://127.0.0.1:{CDP_PORT}/devtools/browser")) + } else { + None + }, + url: None, + missing_dependencies: state.missing_dependencies.clone(), + install_command: state.install_command.clone(), + processes: self.processes_locked(state), + last_error: state.last_error.clone(), + } + } + + fn processes_locked(&self, state: &BrowserRuntimeStateData) -> Vec { + let mut processes = Vec::new(); + if let Some(ref process) = state.xvfb { + processes.push(DesktopProcessInfo { + name: process.name.to_string(), + pid: process.pid, + running: process.running, + log_path: None, + }); + } + if let Some(ref process) = state.chromium { + processes.push(DesktopProcessInfo { + name: process.name.to_string(), + pid: process.pid, + running: process.running, + log_path: None, + }); + } + processes + } + + async fn append_neko_process(&self, response: &mut BrowserStatusResponse) { + if let Some(neko_info) = self.streaming_manager.process_info().await { + response.processes.push(neko_info); + } + } + + fn record_problem_locked(&self, state: &mut BrowserRuntimeStateData, problem: &BrowserProblem) { + state.last_error = Some(problem.to_error_info()); + self.write_runtime_log_locked( + state, + &format!("{}: {}", problem.code(), problem.to_error_info().message), + ); + } + + // ----------------------------------------------------------------------- + // Internal: subprocess management + // ----------------------------------------------------------------------- + + async fn start_xvfb_locked( + &self, + state: &mut BrowserRuntimeStateData, + resolution: &DesktopResolution, + ) -> Result<(), BrowserProblem> { + let Some(display) = state.display.clone() else { + return Err(BrowserProblem::start_failed( + "Display was not configured before starting Xvfb", + )); + }; + let args = vec![ + display, + "-screen".to_string(), + "0".to_string(), + format!("{}x{}x24", resolution.width, resolution.height), + "-dpi".to_string(), + resolution.dpi.unwrap_or(DEFAULT_DPI).to_string(), + "-nolisten".to_string(), + "tcp".to_string(), + ]; + let snapshot = self + .process_runtime + .start_process(ProcessStartSpec { + command: "Xvfb".to_string(), + args, + cwd: None, + env: state.environment.clone(), + tty: false, + interactive: false, + owner: ProcessOwner::Desktop, + restart_policy: Some(RestartPolicy::Always), + }) + .await + .map_err(|err| BrowserProblem::start_failed(format!("failed to start Xvfb: {err}")))?; + state.xvfb = Some(ManagedBrowserProcess { + name: "Xvfb", + process_id: snapshot.id, + pid: snapshot.pid, + running: snapshot.status == ProcessStatus::Running, + }); + Ok(()) + } + + async fn start_chromium_locked( + &self, + state: &mut BrowserRuntimeStateData, + resolution: &DesktopResolution, + headless: bool, + initial_url: Option<&str>, + ) -> Result<(), BrowserProblem> { + let chromium_binary = find_chromium_binary().ok_or_else(|| { + BrowserProblem::install_required( + "Chromium binary not found. Run: sandbox-agent install browser --yes", + ) + })?; + + let mut args = vec![ + "--no-sandbox".to_string(), + "--disable-gpu".to_string(), + "--disable-dev-shm-usage".to_string(), + "--disable-software-rasterizer".to_string(), + format!("--remote-debugging-port={CDP_PORT}"), + "--remote-debugging-address=127.0.0.1".to_string(), + format!("--window-size={},{}", resolution.width, resolution.height), + "--no-first-run".to_string(), + "--no-default-browser-check".to_string(), + ]; + + if headless { + args.push("--headless=new".to_string()); + } + + // Set user-data-dir for persistent contexts + if let Some(ref context_id) = state.context_id { + let context_dir = self + .config + .state_dir + .join("browser-contexts") + .join(context_id); + args.push(format!("--user-data-dir={}", context_dir.display())); + } + + // Initial URL + let url = initial_url.unwrap_or("about:blank"); + args.push(url.to_string()); + + let snapshot = self + .process_runtime + .start_process(ProcessStartSpec { + command: chromium_binary.to_string_lossy().to_string(), + args, + cwd: None, + env: state.environment.clone(), + tty: false, + interactive: false, + owner: ProcessOwner::Desktop, + restart_policy: Some(RestartPolicy::Always), + }) + .await + .map_err(|err| { + BrowserProblem::start_failed(format!("failed to start Chromium: {err}")) + })?; + state.chromium = Some(ManagedBrowserProcess { + name: "chromium", + process_id: snapshot.id, + pid: snapshot.pid, + running: snapshot.status == ProcessStatus::Running, + }); + Ok(()) + } + + async fn stop_xvfb_locked(&self, state: &mut BrowserRuntimeStateData) { + if let Some(process) = state.xvfb.take() { + self.write_runtime_log_locked(state, "stopping Xvfb"); + let _ = self + .process_runtime + .stop_process(&process.process_id, Some(2_000)) + .await; + if self + .process_runtime + .snapshot(&process.process_id) + .await + .ok() + .is_some_and(|snapshot| snapshot.status == ProcessStatus::Running) + { + let _ = self + .process_runtime + .kill_process(&process.process_id, Some(1_000)) + .await; + } + } + } + + async fn stop_chromium_locked(&self, state: &mut BrowserRuntimeStateData) { + if let Some(process) = state.chromium.take() { + self.write_runtime_log_locked(state, "stopping Chromium"); + let _ = self + .process_runtime + .stop_process(&process.process_id, Some(2_000)) + .await; + if self + .process_runtime + .snapshot(&process.process_id) + .await + .ok() + .is_some_and(|snapshot| snapshot.status == ProcessStatus::Running) + { + let _ = self + .process_runtime + .kill_process(&process.process_id, Some(1_000)) + .await; + } + } + } + + async fn fail_start_locked( + &self, + state: &mut BrowserRuntimeStateData, + problem: BrowserProblem, + ) -> BrowserProblem { + self.record_problem_locked(state, &problem); + self.write_runtime_log_locked(state, "browser runtime startup failed; cleaning up"); + + // Close CDP client if any + if let Some(cdp) = state.cdp_client.take() { + cdp.close().await; + } + + self.stop_chromium_locked(state).await; + self.stop_xvfb_locked(state).await; + + state.state = BrowserState::Failed; + state.display = None; + state.resolution = None; + state.started_at = None; + state.environment.clear(); + problem + } + + // ----------------------------------------------------------------------- + // Internal: helpers + // ----------------------------------------------------------------------- + + async fn wait_for_socket(&self, display_num: i32) -> Result<(), BrowserProblem> { + let socket = socket_path(display_num); + let parent = socket + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("/tmp/.X11-unix")); + let _ = fs::create_dir_all(parent); + + let start = tokio::time::Instant::now(); + while start.elapsed() < STARTUP_TIMEOUT { + if socket.exists() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Err(BrowserProblem::timeout(format!( + "timed out waiting for X socket {}", + socket.display() + ))) + } + + async fn wait_for_cdp(&self) -> Result<(), BrowserProblem> { + let url = format!("http://127.0.0.1:{CDP_PORT}/json/version"); + let client = reqwest::Client::new(); + let start = tokio::time::Instant::now(); + + while start.elapsed() < CDP_POLL_TIMEOUT { + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => return Ok(()), + _ => {} + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + Err(BrowserProblem::timeout(format!( + "CDP endpoint at {url} did not become ready within {}s", + CDP_POLL_TIMEOUT.as_secs() + ))) + } + + fn choose_display_num(&self) -> Result { + let start = self.config.display_num; + if start <= 0 { + return Err(BrowserProblem::start_failed("displayNum must be > 0")); + } + for offset in 0..MAX_DISPLAY_PROBE { + let candidate = start + offset; + if !socket_path(candidate).exists() { + return Ok(candidate); + } + } + Err(BrowserProblem::start_failed(format!( + "unable to find an available X display starting at :{start}" + ))) + } + + fn base_environment(&self, display: &str) -> Result, BrowserProblem> { + let mut environment = HashMap::new(); + environment.insert("DISPLAY".to_string(), display.to_string()); + environment.insert( + "HOME".to_string(), + self.config + .state_dir + .join("home") + .to_string_lossy() + .to_string(), + ); + environment.insert( + "USER".to_string(), + std::env::var("USER").unwrap_or_else(|_| "sandbox-agent".to_string()), + ); + environment.insert( + "PATH".to_string(), + std::env::var("PATH").unwrap_or_default(), + ); + fs::create_dir_all(self.config.state_dir.join("home")).map_err(|err| { + BrowserProblem::start_failed(format!("failed to create browser home: {err}")) + })?; + Ok(environment) + } + + fn detect_missing_dependencies(&self) -> Vec { + detect_missing_browser_dependencies() + } + + fn install_command_for(&self, missing_dependencies: &[String]) -> Option { + if !self.platform_supported() || missing_dependencies.is_empty() { + None + } else { + Some("sandbox-agent install browser --yes".to_string()) + } + } + + fn platform_supported(&self) -> bool { + cfg!(target_os = "linux") || self.config.assume_linux_for_tests + } + + fn ensure_state_dir(&self) -> Result<(), String> { + fs::create_dir_all(&self.config.state_dir).map_err(|err| { + format!( + "failed to create browser state dir {}: {err}", + self.config.state_dir.display() + ) + }) + } + + fn write_runtime_log_locked(&self, state: &BrowserRuntimeStateData, message: &str) { + if let Some(parent) = state.runtime_log_path.parent() { + let _ = fs::create_dir_all(parent); + } + let line = format!("{} {}\n", chrono::Utc::now().to_rfc3339(), message); + let _ = OpenOptions::new() + .create(true) + .append(true) + .open(&state.runtime_log_path) + .and_then(|mut file| std::io::Write::write_all(&mut file, line.as_bytes())); + } +} + +// --------------------------------------------------------------------------- +// Free functions +// --------------------------------------------------------------------------- + +fn default_state_dir() -> PathBuf { + if let Ok(value) = std::env::var("XDG_STATE_HOME") { + return PathBuf::from(value).join("sandbox-agent").join("browser"); + } + if let Some(home) = dirs::home_dir() { + return home + .join(".local") + .join("state") + .join("sandbox-agent") + .join("browser"); + } + PathBuf::from("/tmp/sandbox-agent/browser") +} + +fn socket_path(display_num: i32) -> PathBuf { + PathBuf::from(format!("/tmp/.X11-unix/X{display_num}")) +} + +fn find_chromium_binary() -> Option { + find_binary("chromium") + .or_else(|| find_binary("chromium-browser")) + .or_else(|| find_binary("google-chrome")) + .or_else(|| find_binary("google-chrome-stable")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_uses_display_98() { + let config = BrowserRuntimeConfig::default(); + assert_eq!(config.display_num, DEFAULT_DISPLAY_NUM); + } + + #[test] + fn find_chromium_binary_returns_some_on_path() { + // This test is environment-dependent; just ensure no panic + let _ = find_chromium_binary(); + } + + #[test] + fn socket_path_matches_expected_format() { + let path = socket_path(98); + assert_eq!(path, PathBuf::from("/tmp/.X11-unix/X98")); + } + + #[test] + fn install_command_for_empty_deps_is_none() { + let rt = BrowserRuntime::new( + Arc::new(ProcessRuntime::new()), + Arc::new(DesktopRuntime::new(Arc::new(ProcessRuntime::new()))), + ); + assert_eq!(rt.install_command_for(&[]), None); + } +} diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index f7d4da9..d095c5c 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -4,6 +4,7 @@ mod acp_proxy_runtime; mod browser_cdp; mod browser_errors; mod browser_install; +mod browser_runtime; pub mod browser_types; pub mod cli; pub mod daemon; diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 195a5cd..7b19772 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -37,6 +37,7 @@ use tracing::Span; use utoipa::{IntoParams, Modify, OpenApi, ToSchema}; use crate::acp_proxy_runtime::{AcpProxyRuntime, ProxyPostOutcome}; +use crate::browser_runtime::BrowserRuntime; use crate::desktop_errors::DesktopProblem; use crate::desktop_runtime::DesktopRuntime; use crate::desktop_types::*; @@ -92,6 +93,7 @@ pub struct AppState { opencode_server_manager: Arc, process_runtime: Arc, desktop_runtime: Arc, + browser_runtime: Arc, pub(crate) branding: BrandingMode, version_cache: Mutex>, } @@ -117,6 +119,10 @@ impl AppState { )); let process_runtime = Arc::new(ProcessRuntime::new()); let desktop_runtime = Arc::new(DesktopRuntime::new(process_runtime.clone())); + let browser_runtime = Arc::new(BrowserRuntime::new( + process_runtime.clone(), + desktop_runtime.clone(), + )); Self { auth, agent_manager, @@ -124,6 +130,7 @@ impl AppState { opencode_server_manager, process_runtime, desktop_runtime, + browser_runtime, branding, version_cache: Mutex::new(HashMap::new()), } @@ -149,6 +156,10 @@ impl AppState { self.desktop_runtime.clone() } + pub(crate) fn browser_runtime(&self) -> Arc { + self.browser_runtime.clone() + } + pub(crate) fn purge_version_cache(&self, agent: AgentId) { self.version_cache.lock().unwrap().remove(&agent); }