Skip to content

Commit 644a3fc

Browse files
hotzenKai Meder
authored andcommitted
support external browsers
1 parent f0ccd6e commit 644a3fc

3 files changed

Lines changed: 245 additions & 5 deletions

File tree

docs/docker.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,105 @@ volumes:
149149
150150
The `shm_size` and `seccomp` settings are needed for Chromium to run properly in a container.
151151

152+
### External Browser
153+
154+
Run `chromedp/headless-shell` as a separate container and point Spacebot at it via
155+
`connect_url`. This decouples the browser lifecycle from the main process and avoids
156+
bundling Chromium into the Spacebot image.
157+
158+
Workers spawned by the same agent share one Chrome process (each gets its own tab). A
159+
Chrome crash kills all tabs for that agent.
160+
161+
#### Spacebot on host, browser in Docker
162+
163+
When Spacebot runs as a binary directly on the host, expose port 9222 so the host process
164+
can reach the container:
165+
166+
```yaml
167+
# docker-compose.yml
168+
services:
169+
browser:
170+
image: chromedp/headless-shell:latest
171+
container_name: browser
172+
ports:
173+
- "127.0.0.1:9222:9222"
174+
shm_size: 1gb
175+
restart: unless-stopped
176+
```
177+
178+
Test whether the browser is reachable from the host:
179+
180+
```bash
181+
curl http://localhost:9222/json/version
182+
```
183+
184+
Then configure Spacebot via config:
185+
186+
```toml
187+
[defaults.browser]
188+
connect_url = "http://localhost:9222"
189+
```
190+
191+
#### Per-agent dedicated sandboxes
192+
193+
Use a `config.toml` to route each agent to its own container:
194+
195+
```toml
196+
[defaults.browser]
197+
connect_url = "http://browser-main:9222"
198+
199+
[[agents]]
200+
id = "research"
201+
[agents.browser]
202+
connect_url = "http://browser-research:9222"
203+
204+
[[agents]]
205+
id = "internal"
206+
[agents.browser]
207+
enabled = false
208+
```
209+
210+
```yaml
211+
services:
212+
spacebot:
213+
image: ghcr.io/spacedriveapp/spacebot:slim
214+
volumes:
215+
- spacebot-data:/data
216+
- ./config.toml:/data/config.toml:ro
217+
networks:
218+
- spacebot-net
219+
220+
browser-main:
221+
image: chromedp/headless-shell:latest
222+
networks:
223+
- spacebot-net
224+
shm_size: 512mb
225+
restart: unless-stopped
226+
227+
browser-research:
228+
image: chromedp/headless-shell:latest
229+
networks:
230+
- spacebot-net
231+
shm_size: 1gb
232+
restart: unless-stopped
233+
234+
networks:
235+
spacebot-net:
236+
237+
volumes:
238+
spacebot-data:
239+
```
240+
241+
#### `connect_url`
242+
243+
Accepted formats:
244+
- `http://host:9222` — auto-discovers the WebSocket URL via `/json/version` (preferred)
245+
- `ws://host:9222/devtools/browser/<id>` — direct WebSocket URL
246+
247+
An empty string is treated as unset and falls back to the embedded launch path.
248+
249+
If the browser container crashes or the WebSocket drops, the next browser operation returns a clear `"external browser connection lost"` error rather than an opaque protocol failure.
250+
152251
## Building the Image
153252

154253
From the spacebot repo root:

src/config.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,8 @@ pub struct BrowserConfig {
915915
pub executable_path: Option<String>,
916916
/// Directory for storing screenshots and other browser artifacts.
917917
pub screenshot_dir: Option<PathBuf>,
918+
/// CDP URL of an external browser to connect to instead of launching one locally.
919+
pub connect_url: Option<String>,
918920
/// Directory for caching a fetcher-downloaded Chromium binary.
919921
/// Populated from `{instance_dir}/chrome_cache` during config resolution.
920922
pub chrome_cache_dir: PathBuf,
@@ -928,6 +930,7 @@ impl Default for BrowserConfig {
928930
evaluate_enabled: false,
929931
executable_path: None,
930932
screenshot_dir: None,
933+
connect_url: None,
931934
chrome_cache_dir: PathBuf::from("chrome_cache"),
932935
}
933936
}
@@ -2989,6 +2992,7 @@ struct TomlBrowserConfig {
29892992
evaluate_enabled: Option<bool>,
29902993
executable_path: Option<String>,
29912994
screenshot_dir: Option<String>,
2995+
connect_url: Option<String>,
29922996
}
29932997

29942998
#[derive(Deserialize)]
@@ -4841,6 +4845,7 @@ impl Config {
48414845
.map(PathBuf::from)
48424846
.or_else(|| base.screenshot_dir.clone()),
48434847
chrome_cache_dir: chrome_cache_dir.clone(),
4848+
connect_url: b.connect_url.or_else(|| base.connect_url.clone()),
48444849
}
48454850
})
48464851
.unwrap_or_else(|| BrowserConfig {
@@ -5038,6 +5043,9 @@ impl Config {
50385043
.map(PathBuf::from)
50395044
.or_else(|| defaults.browser.screenshot_dir.clone()),
50405045
chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(),
5046+
connect_url: b
5047+
.connect_url
5048+
.or_else(|| defaults.browser.connect_url.clone()),
50415049
}),
50425050
mcp: match a.mcp {
50435051
Some(mcp_servers) => Some(
@@ -5563,6 +5571,13 @@ impl Config {
55635571
});
55645572
}
55655573

5574+
warn_browser_config("defaults", &defaults.browser);
5575+
for agent in &agents {
5576+
if let Some(browser) = &agent.browser {
5577+
warn_browser_config(&agent.id, browser);
5578+
}
5579+
}
5580+
55665581
Ok(Config {
55675582
instance_dir,
55685583
llm,
@@ -5816,6 +5831,28 @@ impl std::fmt::Debug for RuntimeConfig {
58165831
}
58175832
}
58185833

5834+
/// Warn at config load time about `BrowserConfig` fields that have no effect when
5835+
/// `connect_url` is set.
5836+
fn warn_browser_config(context: &str, config: &BrowserConfig) {
5837+
let Some(url) = config.connect_url.as_deref().filter(|u| !u.is_empty()) else {
5838+
return;
5839+
};
5840+
if config.executable_path.is_some() {
5841+
tracing::warn!(
5842+
context,
5843+
connect_url = url,
5844+
"connect_url is set; executable_path has no effect"
5845+
);
5846+
}
5847+
if !config.headless {
5848+
tracing::warn!(
5849+
context,
5850+
connect_url = url,
5851+
"connect_url is set; headless flag has no effect"
5852+
);
5853+
}
5854+
}
5855+
58195856
/// Watches config, prompt, identity, and skill files for changes and triggers
58205857
/// hot reload on the corresponding RuntimeConfig.
58215858
///

src/tools/browser.rs

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize};
2424
use std::collections::HashMap;
2525
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2626
use std::path::{Path, PathBuf};
27+
use std::sync::atomic::{AtomicBool, Ordering};
2728
use std::sync::Arc;
2829
use tokio::sync::Mutex;
2930
use tokio::task::JoinHandle;
@@ -149,6 +150,11 @@ struct BrowserState {
149150
/// Per-launch temp directory for Chrome's user data. Cleaned up on drop to
150151
/// prevent stale singleton locks from blocking subsequent launches.
151152
user_data_dir: Option<PathBuf>,
153+
/// True when connected to an external browser process rather than a locally launched one.
154+
connected: bool,
155+
/// Shared flag set to `false` by the handler task when the WebSocket connection drops.
156+
/// Only meaningful when `connected` is true.
157+
connection_alive: Arc<AtomicBool>,
152158
}
153159

154160
impl Drop for BrowserState {
@@ -180,6 +186,8 @@ impl std::fmt::Debug for BrowserState {
180186
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181187
f.debug_struct("BrowserState")
182188
.field("has_browser", &self.browser.is_some())
189+
.field("connected", &self.connected)
190+
.field("connection_alive", &self.connection_alive.load(Ordering::Relaxed))
183191
.field("pages", &self.pages.len())
184192
.field("active_target", &self.active_target)
185193
.field("element_refs", &self.element_refs.len())
@@ -210,6 +218,8 @@ impl BrowserTool {
210218
element_refs: HashMap::new(),
211219
next_ref: 0,
212220
user_data_dir: None,
221+
connected: false,
222+
connection_alive: Arc::new(AtomicBool::new(false)),
213223
})),
214224
config,
215225
screenshot_dir,
@@ -505,6 +515,58 @@ impl BrowserTool {
505515
}
506516
}
507517

518+
let is_connect = self
519+
.config
520+
.connect_url
521+
.as_deref()
522+
.is_some_and(|url| !url.is_empty());
523+
524+
if is_connect {
525+
self.connect_external().await
526+
} else {
527+
self.launch_local().await
528+
}
529+
}
530+
531+
async fn connect_external(&self) -> Result<BrowserOutput, BrowserError> {
532+
let connect_url = self.config.connect_url.as_deref().unwrap();
533+
534+
tracing::info!(connect_url, "connecting to external browser");
535+
536+
let (browser, mut handler) = Browser::connect(connect_url).await.map_err(|error| {
537+
BrowserError::new(format!(
538+
"failed to connect to browser at {connect_url}: {error}"
539+
))
540+
})?;
541+
542+
let connection_alive = Arc::new(AtomicBool::new(true));
543+
let alive_flag = connection_alive.clone();
544+
let handler_task = tokio::spawn(async move {
545+
while handler.next().await.is_some() {}
546+
alive_flag.store(false, Ordering::Release);
547+
});
548+
549+
let mut state = self.state.lock().await;
550+
551+
// Guard against a concurrent launch that won the race.
552+
if state.browser.is_some() {
553+
drop(browser);
554+
handler_task.abort();
555+
return Ok(BrowserOutput::success("Browser already running"));
556+
}
557+
558+
state.browser = Some(browser);
559+
state._handler_task = Some(handler_task);
560+
state.connected = true;
561+
state.connection_alive = connection_alive;
562+
563+
tracing::info!(connect_url, "connected to external browser");
564+
Ok(BrowserOutput::success(format!(
565+
"Connected to external browser at {connect_url}"
566+
)))
567+
}
568+
569+
async fn launch_local(&self) -> Result<BrowserOutput, BrowserError> {
508570
// Resolve the Chrome executable path (may download ~150MB on first use):
509571
// 1. Explicit config override
510572
// 2. CHROME / CHROME_PATH env vars
@@ -551,13 +613,15 @@ impl BrowserTool {
551613
// Another call launched while we were downloading/starting. Clean up
552614
// the browser we just created and return success.
553615
drop(browser);
616+
handler_task.abort();
554617
let _ = std::fs::remove_dir_all(&user_data_dir);
555618
return Ok(BrowserOutput::success("Browser already running"));
556619
}
557620

558621
state.browser = Some(browser);
559622
state._handler_task = Some(handler_task);
560623
state.user_data_dir = Some(user_data_dir);
624+
state.connected = false;
561625

562626
tracing::info!("browser launched");
563627
Ok(BrowserOutput::success("Browser launched successfully"))
@@ -1013,17 +1077,48 @@ impl BrowserTool {
10131077
async fn handle_close(&self) -> Result<BrowserOutput, BrowserError> {
10141078
let mut state = self.state.lock().await;
10151079

1080+
if state.connected {
1081+
self.disconnect(&mut state).await
1082+
} else {
1083+
self.close(&mut state).await
1084+
}
1085+
}
1086+
1087+
async fn disconnect(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
1088+
// Close all pages we opened so they don't linger as orphan tabs in the container.
1089+
for (id, page) in state.pages.drain() {
1090+
if let Err(error) = page.close().await {
1091+
tracing::debug!(target_id = %id, %error, "failed to close page during disconnect");
1092+
}
1093+
}
1094+
// Drop without Browser.close — that CDP command would terminate the external process.
1095+
state.browser.take();
1096+
self.reset_state(state).await;
1097+
tracing::info!("external browser disconnected");
1098+
Ok(BrowserOutput::success("Browser disconnected"))
1099+
}
1100+
1101+
async fn close(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
10161102
if let Some(mut browser) = state.browser.take()
10171103
&& let Err(error) = browser.close().await
10181104
{
1019-
tracing::warn!(%error, "browser close returned error");
1105+
tracing::warn!(%error, "embedded browser close returned error");
10201106
}
1107+
self.reset_state(state).await;
1108+
tracing::info!("embedded browser closed");
1109+
Ok(BrowserOutput::success("Browser closed"))
1110+
}
10211111

1112+
async fn reset_state(&self, state: &mut BrowserState) {
10221113
state.pages.clear();
10231114
state.active_target = None;
10241115
state.element_refs.clear();
10251116
state.next_ref = 0;
1026-
state._handler_task = None;
1117+
if let Some(task) = state._handler_task.take() {
1118+
task.abort();
1119+
}
1120+
state.connected = false;
1121+
state.connection_alive = Arc::new(AtomicBool::new(false));
10271122

10281123
// Clean up the per-launch user data dir to free disk space.
10291124
if let Some(dir) = state.user_data_dir.take()
@@ -1035,9 +1130,6 @@ impl BrowserTool {
10351130
"failed to clean up browser user data dir"
10361131
);
10371132
}
1038-
1039-
tracing::info!("browser closed");
1040-
Ok(BrowserOutput::success("Browser closed"))
10411133
}
10421134

10431135
/// Get the active page, or create a first one if the browser has no pages yet.
@@ -1046,6 +1138,12 @@ impl BrowserTool {
10461138
state: &'a mut BrowserState,
10471139
url: Option<&str>,
10481140
) -> Result<&'a chromiumoxide::Page, BrowserError> {
1141+
if state.connected && !state.connection_alive.load(Ordering::Acquire) {
1142+
return Err(BrowserError::new(
1143+
"external browser connection lost — reconnect with launch",
1144+
));
1145+
}
1146+
10491147
if let Some(target) = state.active_target.as_ref()
10501148
&& state.pages.contains_key(target)
10511149
{
@@ -1075,6 +1173,12 @@ impl BrowserTool {
10751173
&self,
10761174
state: &'a BrowserState,
10771175
) -> Result<&'a chromiumoxide::Page, BrowserError> {
1176+
if state.connected && !state.connection_alive.load(Ordering::Acquire) {
1177+
return Err(BrowserError::new(
1178+
"external browser connection lost — reconnect with launch",
1179+
));
1180+
}
1181+
10781182
let target = state
10791183
.active_target
10801184
.as_ref()

0 commit comments

Comments
 (0)