Skip to content

Commit b190811

Browse files
hotzenKai Meder
authored andcommitted
support external browsers
1 parent af095f3 commit b190811

3 files changed

Lines changed: 236 additions & 6 deletions

File tree

docs/docker.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,139 @@ 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+
#### Both in Docker
192+
193+
When both Spacebot and the browser run in containers, use a Docker network instead of
194+
exposing ports:
195+
196+
```yaml
197+
services:
198+
spacebot:
199+
image: ghcr.io/spacedriveapp/spacebot:slim
200+
ports:
201+
- "19898:19898"
202+
volumes:
203+
- spacebot-data:/data
204+
environment:
205+
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
206+
volumes:
207+
- spacebot-data:/data
208+
- ./config.toml:/data/config.toml:ro
209+
networks:
210+
- spacebot-net
211+
restart: unless-stopped
212+
213+
browser:
214+
image: chromedp/headless-shell:latest
215+
networks:
216+
- spacebot-net
217+
shm_size: 1gb
218+
restart: unless-stopped
219+
220+
networks:
221+
spacebot-net:
222+
223+
volumes:
224+
spacebot-data:
225+
```
226+
227+
#### Per-agent dedicated sandboxes
228+
229+
Use a `config.toml` to route each agent to its own container:
230+
231+
```toml
232+
[defaults.browser]
233+
connect_url = "http://browser-main:9222"
234+
235+
[[agents]]
236+
id = "research"
237+
[agents.browser]
238+
connect_url = "http://browser-research:9222"
239+
240+
[[agents]]
241+
id = "internal"
242+
[agents.browser]
243+
enabled = false
244+
```
245+
246+
```yaml
247+
services:
248+
spacebot:
249+
image: ghcr.io/spacedriveapp/spacebot:slim
250+
volumes:
251+
- spacebot-data:/data
252+
- ./config.toml:/data/config.toml:ro
253+
networks:
254+
- spacebot-net
255+
256+
browser-main:
257+
image: chromedp/headless-shell:latest
258+
networks:
259+
- spacebot-net
260+
shm_size: 512mb
261+
restart: unless-stopped
262+
263+
browser-research:
264+
image: chromedp/headless-shell:latest
265+
networks:
266+
- spacebot-net
267+
shm_size: 1gb
268+
restart: unless-stopped
269+
270+
networks:
271+
spacebot-net:
272+
273+
volumes:
274+
spacebot-data:
275+
```
276+
277+
#### `connect_url`
278+
279+
Accepted formats:
280+
- `http://host:9222` — auto-discovers the WebSocket URL via `/json/version` (preferred)
281+
- `ws://host:9222/devtools/browser/<id>` — direct WebSocket URL
282+
283+
An empty string is treated as unset and falls back to the embedded launch path.
284+
152285
## Building the Image
153286

154287
From the spacebot repo root:

src/config.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,8 @@ pub struct BrowserConfig {
522522
pub executable_path: Option<String>,
523523
/// Directory for storing screenshots and other browser artifacts.
524524
pub screenshot_dir: Option<PathBuf>,
525+
/// CDP URL of an external browser to connect to instead of launching one locally.
526+
pub connect_url: Option<String>,
525527
}
526528

527529
impl Default for BrowserConfig {
@@ -532,6 +534,7 @@ impl Default for BrowserConfig {
532534
evaluate_enabled: false,
533535
executable_path: None,
534536
screenshot_dir: None,
537+
connect_url: None,
535538
}
536539
}
537540
}
@@ -1801,6 +1804,7 @@ struct TomlBrowserConfig {
18011804
evaluate_enabled: Option<bool>,
18021805
executable_path: Option<String>,
18031806
screenshot_dir: Option<String>,
1807+
connect_url: Option<String>,
18041808
}
18051809

18061810
#[derive(Deserialize)]
@@ -3109,6 +3113,7 @@ impl Config {
31093113
.screenshot_dir
31103114
.map(PathBuf::from)
31113115
.or_else(|| base.screenshot_dir.clone()),
3116+
connect_url: b.connect_url.or_else(|| base.connect_url.clone()),
31123117
}
31133118
})
31143119
.unwrap_or_else(|| base_defaults.browser.clone()),
@@ -3295,6 +3300,9 @@ impl Config {
32953300
.screenshot_dir
32963301
.map(PathBuf::from)
32973302
.or_else(|| defaults.browser.screenshot_dir.clone()),
3303+
connect_url: b
3304+
.connect_url
3305+
.or_else(|| defaults.browser.connect_url.clone()),
32983306
}),
32993307
mcp: match a.mcp {
33003308
Some(mcp_servers) => Some(
@@ -3546,6 +3554,13 @@ impl Config {
35463554
});
35473555
}
35483556

3557+
warn_browser_config("defaults", &defaults.browser);
3558+
for agent in &agents {
3559+
if let Some(browser) = &agent.browser {
3560+
warn_browser_config(&agent.id, browser);
3561+
}
3562+
}
3563+
35493564
Ok(Config {
35503565
instance_dir,
35513566
llm,
@@ -3787,6 +3802,28 @@ impl std::fmt::Debug for RuntimeConfig {
37873802
}
37883803
}
37893804

3805+
/// Warn at config load time about `BrowserConfig` fields that have no effect when
3806+
/// `connect_url` is set.
3807+
fn warn_browser_config(context: &str, config: &BrowserConfig) {
3808+
let Some(url) = config.connect_url.as_deref().filter(|u| !u.is_empty()) else {
3809+
return;
3810+
};
3811+
if config.executable_path.is_some() {
3812+
tracing::warn!(
3813+
context,
3814+
connect_url = url,
3815+
"connect_url is set; executable_path has no effect"
3816+
);
3817+
}
3818+
if !config.headless {
3819+
tracing::warn!(
3820+
context,
3821+
connect_url = url,
3822+
"connect_url is set; headless flag has no effect"
3823+
);
3824+
}
3825+
}
3826+
37903827
/// Watches config, prompt, identity, and skill files for changes and triggers
37913828
/// hot reload on the corresponding RuntimeConfig.
37923829
///

src/tools/browser.rs

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,15 @@ struct BrowserState {
145145
element_refs: HashMap<String, ElementRef>,
146146
/// Counter for generating element refs.
147147
next_ref: usize,
148+
/// True when connected to an external browser process rather than a locally launched one.
149+
connected: bool,
148150
}
149151

150152
impl std::fmt::Debug for BrowserState {
151153
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152154
f.debug_struct("BrowserState")
153155
.field("has_browser", &self.browser.is_some())
156+
.field("connected", &self.connected)
154157
.field("pages", &self.pages.len())
155158
.field("active_target", &self.active_target)
156159
.field("element_refs", &self.element_refs.len())
@@ -180,6 +183,7 @@ impl BrowserTool {
180183
active_target: None,
181184
element_refs: HashMap::new(),
182185
next_ref: 0,
186+
connected: false,
183187
})),
184188
config,
185189
screenshot_dir,
@@ -472,12 +476,48 @@ impl BrowserTool {
472476
return Ok(BrowserOutput::success("Browser already running"));
473477
}
474478

479+
let is_connect = self
480+
.config
481+
.connect_url
482+
.as_deref()
483+
.is_some_and(|url| !url.is_empty());
484+
485+
if is_connect {
486+
self.connect(&mut state).await
487+
} else {
488+
self.launch(&mut state).await
489+
}
490+
}
491+
492+
async fn connect(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
493+
let connect_url = self.config.connect_url.as_deref().unwrap();
494+
495+
tracing::info!(connect_url, "connecting to external browser");
496+
497+
let (browser, mut handler) = Browser::connect(connect_url).await.map_err(|error| {
498+
BrowserError::new(format!(
499+
"failed to connect to browser at {connect_url}: {error}"
500+
))
501+
})?;
502+
503+
let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
504+
505+
state.browser = Some(browser);
506+
state._handler_task = Some(handler_task);
507+
state.connected = true;
508+
509+
tracing::info!(connect_url, "connected to external browser");
510+
Ok(BrowserOutput::success(format!(
511+
"Connected to external browser at {connect_url}"
512+
)))
513+
}
514+
515+
async fn launch(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
475516
let mut builder = ChromeConfig::builder().no_sandbox();
476517

477518
if !self.config.headless {
478519
builder = builder.with_head().window_size(1280, 900);
479520
}
480-
481521
if let Some(path) = &self.config.executable_path {
482522
builder = builder.chrome_executable(path);
483523
}
@@ -489,7 +529,7 @@ impl BrowserTool {
489529
tracing::info!(
490530
headless = self.config.headless,
491531
executable = ?self.config.executable_path,
492-
"launching chrome"
532+
"launching browser"
493533
);
494534

495535
let (browser, mut handler) = Browser::launch(chrome_config)
@@ -500,6 +540,7 @@ impl BrowserTool {
500540

501541
state.browser = Some(browser);
502542
state._handler_task = Some(handler_task);
543+
state.connected = false;
503544

504545
tracing::info!("browser launched");
505546
Ok(BrowserOutput::success("Browser launched successfully"))
@@ -955,20 +996,39 @@ impl BrowserTool {
955996
async fn handle_close(&self) -> Result<BrowserOutput, BrowserError> {
956997
let mut state = self.state.lock().await;
957998

999+
if state.connected {
1000+
self.disconnect(&mut state).await
1001+
} else {
1002+
self.close(&mut state).await
1003+
}
1004+
}
1005+
1006+
async fn disconnect(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
1007+
// Drop without Browser.close — that CDP command would terminate the external process.
1008+
state.browser.take();
1009+
self.reset_state(state);
1010+
tracing::info!("external browser disconnected");
1011+
Ok(BrowserOutput::success("Browser disconnected"))
1012+
}
1013+
1014+
async fn close(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
9581015
if let Some(mut browser) = state.browser.take()
9591016
&& let Err(error) = browser.close().await
9601017
{
961-
tracing::warn!(%error, "browser close returned error");
1018+
tracing::warn!(%error, "embedded browser close returned error");
9621019
}
1020+
self.reset_state(state);
1021+
tracing::info!("embedded browser closed");
1022+
Ok(BrowserOutput::success("Browser closed"))
1023+
}
9631024

1025+
fn reset_state(&self, state: &mut BrowserState) {
9641026
state.pages.clear();
9651027
state.active_target = None;
9661028
state.element_refs.clear();
9671029
state.next_ref = 0;
9681030
state._handler_task = None;
969-
970-
tracing::info!("browser closed");
971-
Ok(BrowserOutput::success("Browser closed"))
1031+
state.connected = false;
9721032
}
9731033

9741034
/// Get the active page, or create a first one if the browser has no pages yet.

0 commit comments

Comments
 (0)