mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 19:05:18 +00:00
feat: [US-005] - Add BrowserRuntime state machine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0bd34f6a8d
commit
1d2c43ae36
3 changed files with 994 additions and 0 deletions
982
server/packages/sandbox-agent/src/browser_runtime.rs
Normal file
982
server/packages/sandbox-agent/src/browser_runtime.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue