Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` — 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:
Expand Down
37 changes: 37 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ pub struct BrowserConfig {
pub executable_path: Option<String>,
/// Directory for storing screenshots and other browser artifacts.
pub screenshot_dir: Option<PathBuf>,
/// CDP URL of an external browser to connect to instead of launching one locally.
pub connect_url: Option<String>,
}

impl Default for BrowserConfig {
Expand All @@ -489,6 +491,7 @@ impl Default for BrowserConfig {
evaluate_enabled: false,
executable_path: None,
screenshot_dir: None,
connect_url: None,
}
}
}
Expand Down Expand Up @@ -1555,6 +1558,7 @@ struct TomlBrowserConfig {
evaluate_enabled: Option<bool>,
executable_path: Option<String>,
screenshot_dir: Option<String>,
connect_url: Option<String>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
///
Expand Down
72 changes: 66 additions & 6 deletions src/tools/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,15 @@ struct BrowserState {
element_refs: HashMap<String, ElementRef>,
/// 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())
Expand Down Expand Up @@ -181,6 +184,7 @@ impl BrowserTool {
active_target: None,
element_refs: HashMap::new(),
next_ref: 0,
connected: false,
})),
config,
screenshot_dir,
Expand Down Expand Up @@ -473,12 +477,48 @@ 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<BrowserOutput, BrowserError> {
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<BrowserOutput, BrowserError> {
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);
}
Expand All @@ -490,7 +530,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)
Expand All @@ -501,6 +541,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"))
Expand Down Expand Up @@ -956,20 +997,39 @@ impl BrowserTool {
async fn handle_close(&self) -> Result<BrowserOutput, BrowserError> {
let mut state = self.state.lock().await;

if state.connected {
self.disconnect(&mut state).await
} else {
self.close(&mut state).await
}
}

async fn disconnect(&self, state: &mut BrowserState) -> Result<BrowserOutput, BrowserError> {
// 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<BrowserOutput, BrowserError> {
if let Some(mut browser) = state.browser.take()
&& let Err(error) = browser.close().await
{
tracing::warn!(%error, "browser close returned error");
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.
Expand Down