From 85ff03382272bc8e0616a7104b469c78902d61c0 Mon Sep 17 00:00:00 2001 From: Kai Meder Date: Sun, 22 Feb 2026 23:05:35 +0100 Subject: [PATCH 1/3] support external browsers --- docs/docker.md | 133 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 37 ++++++++++++ src/tools/browser.rs | 90 ++++++++++++++++++++++++++--- 3 files changed, 251 insertions(+), 9 deletions(-) diff --git a/docs/docker.md b/docs/docker.md index 88cc3a8f8..e4bdf8d42 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -149,6 +149,139 @@ volumes: The `shm_size` and `seccomp` settings are needed for Chromium to run properly in a container. +### External Browser + +Run `chromedp/headless-shell` as a separate container and point Spacebot at it via +`connect_url`. This decouples the browser lifecycle from the main process and avoids +bundling Chromium into the Spacebot image. + +Workers spawned by the same agent share one Chrome process (each gets its own tab). A +Chrome crash kills all tabs for that agent. + +#### Spacebot on host, browser in Docker + +When Spacebot runs as a binary directly on the host, expose port 9222 so the host process +can reach the container: + +```yaml +# docker-compose.yml +services: + browser: + image: chromedp/headless-shell:latest + container_name: browser + ports: + - "127.0.0.1:9222:9222" + shm_size: 1gb + restart: unless-stopped +``` + +Test whether the browser is reachable from the host: + +```bash +curl http://localhost:9222/json/version +``` + +Then configure Spacebot via config: + +```toml +[defaults.browser] +connect_url = "http://localhost:9222" +``` + +#### Both in Docker + +When both Spacebot and the browser run in containers, use a Docker network instead of +exposing ports: + +```yaml +services: + spacebot: + image: ghcr.io/spacedriveapp/spacebot:slim + ports: + - "19898:19898" + volumes: + - spacebot-data:/data + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - spacebot-data:/data + - ./config.toml:/data/config.toml:ro + networks: + - spacebot-net + restart: unless-stopped + + browser: + image: chromedp/headless-shell:latest + networks: + - spacebot-net + shm_size: 1gb + restart: unless-stopped + +networks: + spacebot-net: + +volumes: + spacebot-data: +``` + +#### Per-agent dedicated sandboxes + +Use a `config.toml` to route each agent to its own container: + +```toml +[defaults.browser] +connect_url = "http://browser-main:9222" + +[[agents]] +id = "research" +[agents.browser] +connect_url = "http://browser-research:9222" + +[[agents]] +id = "internal" +[agents.browser] +enabled = false +``` + +```yaml +services: + spacebot: + image: ghcr.io/spacedriveapp/spacebot:slim + volumes: + - spacebot-data:/data + - ./config.toml:/data/config.toml:ro + networks: + - spacebot-net + + browser-main: + image: chromedp/headless-shell:latest + networks: + - spacebot-net + shm_size: 512mb + restart: unless-stopped + + browser-research: + image: chromedp/headless-shell:latest + networks: + - spacebot-net + shm_size: 1gb + restart: unless-stopped + +networks: + spacebot-net: + +volumes: + spacebot-data: +``` + +#### `connect_url` + +Accepted formats: +- `http://host:9222` — auto-discovers the WebSocket URL via `/json/version` (preferred) +- `ws://host:9222/devtools/browser/` — direct WebSocket URL + +An empty string is treated as unset and falls back to the embedded launch path. + ## Building the Image From the spacebot repo root: diff --git a/src/config.rs b/src/config.rs index 20e3394d7..4b6a0de40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -479,6 +479,8 @@ pub struct BrowserConfig { pub executable_path: Option, /// Directory for storing screenshots and other browser artifacts. pub screenshot_dir: Option, + /// CDP URL of an external browser to connect to instead of launching one locally. + pub connect_url: Option, } impl Default for BrowserConfig { @@ -489,6 +491,7 @@ impl Default for BrowserConfig { evaluate_enabled: false, executable_path: None, screenshot_dir: None, + connect_url: None, } } } @@ -1555,6 +1558,7 @@ struct TomlBrowserConfig { evaluate_enabled: Option, executable_path: Option, screenshot_dir: Option, + connect_url: Option, } #[derive(Deserialize)] @@ -2721,6 +2725,7 @@ impl Config { .screenshot_dir .map(PathBuf::from) .or_else(|| base.screenshot_dir.clone()), + connect_url: b.connect_url.or_else(|| base.connect_url.clone()), } }) .unwrap_or_else(|| base_defaults.browser.clone()), @@ -2895,6 +2900,9 @@ impl Config { .screenshot_dir .map(PathBuf::from) .or_else(|| defaults.browser.screenshot_dir.clone()), + connect_url: b + .connect_url + .or_else(|| defaults.browser.connect_url.clone()), }), mcp: match a.mcp { Some(mcp_servers) => Some( @@ -3091,6 +3099,13 @@ impl Config { } }; + warn_browser_config("defaults", &defaults.browser); + for agent in &agents { + if let Some(browser) = &agent.browser { + warn_browser_config(&agent.id, browser); + } + } + Ok(Config { instance_dir, llm, @@ -3302,6 +3317,28 @@ impl std::fmt::Debug for RuntimeConfig { } } +/// Warn at config load time about `BrowserConfig` fields that have no effect when +/// `connect_url` is set. +fn warn_browser_config(context: &str, config: &BrowserConfig) { + let Some(url) = config.connect_url.as_deref().filter(|u| !u.is_empty()) else { + return; + }; + if config.executable_path.is_some() { + tracing::warn!( + context, + connect_url = url, + "connect_url is set; executable_path has no effect" + ); + } + if !config.headless { + tracing::warn!( + context, + connect_url = url, + "connect_url is set; headless flag has no effect" + ); + } +} + /// Watches config, prompt, identity, and skill files for changes and triggers /// hot reload on the corresponding RuntimeConfig. /// diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 2d912b69e..322b1b558 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -146,12 +146,15 @@ struct BrowserState { element_refs: HashMap, /// Counter for generating element refs. next_ref: usize, + /// True when connected to an external browser process rather than a locally launched one. + connected: bool, } impl std::fmt::Debug for BrowserState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BrowserState") .field("has_browser", &self.browser.is_some()) + .field("connected", &self.connected) .field("pages", &self.pages.len()) .field("active_target", &self.active_target) .field("element_refs", &self.element_refs.len()) @@ -181,6 +184,7 @@ impl BrowserTool { active_target: None, element_refs: HashMap::new(), next_ref: 0, + connected: false, })), config, screenshot_dir, @@ -473,12 +477,54 @@ impl BrowserTool { return Ok(BrowserOutput::success("Browser already running")); } + let is_connect = self + .config + .connect_url + .as_deref() + .is_some_and(|url| !url.is_empty()); + + if is_connect { + self.connect(&mut state).await + } else { + self.launch(&mut state).await + } + } + + async fn connect( + &self, + state: &mut BrowserState, + ) -> Result { + let connect_url = self.config.connect_url.as_deref().unwrap(); + + tracing::info!(connect_url, "connecting to external browser"); + + let (browser, mut handler) = Browser::connect(connect_url).await.map_err(|error| { + BrowserError::new(format!( + "failed to connect to browser at {connect_url}: {error}" + )) + })?; + + let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} }); + + state.browser = Some(browser); + state._handler_task = Some(handler_task); + state.connected = true; + + tracing::info!(connect_url, "connected to external browser"); + Ok(BrowserOutput::success(format!( + "Connected to external browser at {connect_url}" + ))) + } + + async fn launch( + &self, + state: &mut BrowserState, + ) -> Result { let mut builder = ChromeConfig::builder().no_sandbox(); if !self.config.headless { builder = builder.with_head().window_size(1280, 900); } - if let Some(path) = &self.config.executable_path { builder = builder.chrome_executable(path); } @@ -490,7 +536,7 @@ impl BrowserTool { tracing::info!( headless = self.config.headless, executable = ?self.config.executable_path, - "launching chrome" + "launching browser" ); let (browser, mut handler) = Browser::launch(chrome_config) @@ -501,6 +547,7 @@ impl BrowserTool { state.browser = Some(browser); state._handler_task = Some(handler_task); + state.connected = false; tracing::info!("browser launched"); Ok(BrowserOutput::success("Browser launched successfully")) @@ -956,20 +1003,45 @@ impl BrowserTool { async fn handle_close(&self) -> Result { let mut state = self.state.lock().await; - if let Some(mut browser) = state.browser.take() - && let Err(error) = browser.close().await - { - tracing::warn!(%error, "browser close returned error"); + if state.connected { + self.disconnect(&mut state).await + } else { + self.close(&mut state).await } + } + + async fn disconnect( + &self, + state: &mut BrowserState, + ) -> Result { + // Drop without Browser.close — that CDP command would terminate the external process. + state.browser.take(); + self.reset_state(state); + tracing::info!("external browser disconnected"); + Ok(BrowserOutput::success("Browser disconnected")) + } + async fn close( + &self, + state: &mut BrowserState, + ) -> Result { + if let Some(mut browser) = state.browser.take() { + if let Err(error) = browser.close().await { + tracing::warn!(%error, "embedded browser close returned error"); + } + } + self.reset_state(state); + tracing::info!("embedded browser closed"); + Ok(BrowserOutput::success("Browser closed")) + } + + fn reset_state(&self, state: &mut BrowserState) { state.pages.clear(); state.active_target = None; state.element_refs.clear(); state.next_ref = 0; state._handler_task = None; - - tracing::info!("browser closed"); - Ok(BrowserOutput::success("Browser closed")) + state.connected = false; } /// Get the active page, or create a first one if the browser has no pages yet. From 19d017c68ccca7d5517c45fa06665e9fef433e0a Mon Sep 17 00:00:00 2001 From: Kai Meder Date: Tue, 24 Feb 2026 00:14:15 +0100 Subject: [PATCH 2/3] cargo fmt --- src/tools/browser.rs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 322b1b558..1447d21a8 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -490,10 +490,7 @@ impl BrowserTool { } } - async fn connect( - &self, - state: &mut BrowserState, - ) -> Result { + async fn connect(&self, state: &mut BrowserState) -> Result { let connect_url = self.config.connect_url.as_deref().unwrap(); tracing::info!(connect_url, "connecting to external browser"); @@ -516,10 +513,7 @@ impl BrowserTool { ))) } - async fn launch( - &self, - state: &mut BrowserState, - ) -> Result { + async fn launch(&self, state: &mut BrowserState) -> Result { let mut builder = ChromeConfig::builder().no_sandbox(); if !self.config.headless { @@ -1010,10 +1004,7 @@ impl BrowserTool { } } - async fn disconnect( - &self, - state: &mut BrowserState, - ) -> Result { + async fn disconnect(&self, state: &mut BrowserState) -> Result { // Drop without Browser.close — that CDP command would terminate the external process. state.browser.take(); self.reset_state(state); @@ -1021,10 +1012,7 @@ impl BrowserTool { Ok(BrowserOutput::success("Browser disconnected")) } - async fn close( - &self, - state: &mut BrowserState, - ) -> Result { + async fn close(&self, state: &mut BrowserState) -> Result { if let Some(mut browser) = state.browser.take() { if let Err(error) = browser.close().await { tracing::warn!(%error, "embedded browser close returned error"); From 5cbd34cfd1c2ec7698314113d633790c5965223c Mon Sep 17 00:00:00 2001 From: Kai Meder Date: Tue, 24 Feb 2026 00:17:54 +0100 Subject: [PATCH 3/3] fix clippy --- src/tools/browser.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 1447d21a8..5483d0f51 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -1013,10 +1013,10 @@ impl BrowserTool { } async fn close(&self, state: &mut BrowserState) -> Result { - if let Some(mut browser) = state.browser.take() { - if let Err(error) = browser.close().await { - tracing::warn!(%error, "embedded browser close returned error"); - } + if let Some(mut browser) = state.browser.take() + && let Err(error) = browser.close().await + { + tracing::warn!(%error, "embedded browser close returned error"); } self.reset_state(state); tracing::info!("embedded browser closed");