feat: [US-005] - Add BrowserRuntime state machine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 05:01:44 -07:00
parent 0bd34f6a8d
commit 1d2c43ae36
3 changed files with 994 additions and 0 deletions

View file

@ -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<ProcessRuntime>,
desktop_runtime: Arc<DesktopRuntime>,
streaming_manager: DesktopStreamingManager,
inner: Arc<Mutex<BrowserRuntimeStateData>>,
}
#[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<String>,
resolution: Option<DesktopResolution>,
started_at: Option<String>,
last_error: Option<DesktopErrorInfo>,
missing_dependencies: Vec<String>,
install_command: Option<String>,
runtime_log_path: PathBuf,
environment: HashMap<String, String>,
xvfb: Option<ManagedBrowserProcess>,
chromium: Option<ManagedBrowserProcess>,
cdp_client: Option<CdpClient>,
context_id: Option<String>,
streaming_config: Option<crate::desktop_streaming::StreamingConfig>,
recording_fps: Option<u32>,
console_messages: VecDeque<BrowserConsoleMessage>,
network_requests: VecDeque<BrowserNetworkRequest>,
}
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<u32>,
running: bool,
}
impl BrowserRuntime {
pub fn new(process_runtime: Arc<ProcessRuntime>, desktop_runtime: Arc<DesktopRuntime>) -> Self {
Self::with_config(
process_runtime,
desktop_runtime,
BrowserRuntimeConfig::default(),
)
}
pub fn with_config(
process_runtime: Arc<ProcessRuntime>,
desktop_runtime: Arc<DesktopRuntime>,
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<BrowserStatusResponse, BrowserProblem> {
// 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<BrowserStatusResponse, BrowserProblem> {
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<CdpClient, BrowserProblem> {
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<F, Fut, T>(&self, f: F) -> Result<T, BrowserProblem>
where
F: FnOnce(&CdpClient) -> Fut,
Fut: std::future::Future<Output = Result<T, BrowserProblem>>,
{
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<u32>,
) -> Vec<BrowserConsoleMessage> {
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::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
/// Get network requests, optionally filtered by URL pattern.
pub async fn network_requests(
&self,
url_pattern: Option<&str>,
limit: Option<u32>,
) -> Vec<BrowserNetworkRequest> {
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::<Vec<_>>()
.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<DesktopProcessInfo> {
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<i32, BrowserProblem> {
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<HashMap<String, String>, 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<String> {
detect_missing_browser_dependencies()
}
fn install_command_for(&self, missing_dependencies: &[String]) -> Option<String> {
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<PathBuf> {
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);
}
}

View file

@ -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;

View file

@ -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<OpenCodeServerManager>,
process_runtime: Arc<ProcessRuntime>,
desktop_runtime: Arc<DesktopRuntime>,
browser_runtime: Arc<BrowserRuntime>,
pub(crate) branding: BrandingMode,
version_cache: Mutex<HashMap<AgentId, CachedAgentVersion>>,
}
@ -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<BrowserRuntime> {
self.browser_runtime.clone()
}
pub(crate) fn purge_version_cache(&self, agent: AgentId) {
self.version_cache.lock().unwrap().remove(&agent);
}