feat(sandbox): separate bind mounts for browser containers (#16230)

* feat(sandbox): add separate browser.binds config for browser containers

Allow configuring bind mounts independently for browser containers via
sandbox.browser.binds. When set, browser containers use browser-specific
binds instead of inheriting docker.binds. Falls back to docker.binds
when browser.binds is not configured for backwards compatibility.

Closes #14614

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sandbox): honor empty browser binds override (#16230) (thanks @seheepeak)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
seheepeak 2026-02-14 23:27:41 +09:00 committed by GitHub
parent 302dafbe1a
commit cb9a5e1cb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 104 additions and 1 deletions

View File

@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
### Fixes
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.

View File

@ -933,6 +933,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in main config.
- `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser.
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
</Accordion>

View File

@ -71,6 +71,11 @@ Format: `host:container:mode` (e.g., `"/home/user/source:/source:rw"`).
Global and per-agent binds are **merged** (not replaced). Under `scope: "shared"`, per-agent binds are ignored.
`agents.defaults.sandbox.browser.binds` mounts additional host directories into the **sandbox browser** container only.
- When set (including `[]`), it replaces `agents.defaults.sandbox.docker.binds` for the browser container.
- When omitted, the browser container falls back to `agents.defaults.sandbox.docker.binds` (backwards compatible).
Example (read-only source + docker socket):
```json5

View File

@ -106,9 +106,13 @@ export async function ensureSandboxBrowser(params: {
const state = await dockerContainerState(containerName);
if (!state.exists) {
await ensureSandboxBrowserImage(params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE);
const browserDockerCfg =
params.cfg.browser.binds !== undefined
? { ...params.cfg.docker, network: "bridge", binds: params.cfg.browser.binds }
: { ...params.cfg.docker, network: "bridge" };
const args = buildSandboxCreateArgs({
name: containerName,
cfg: { ...params.cfg.docker, network: "bridge" },
cfg: browserDockerCfg,
scopeKey: params.scopeKey,
labels: { "openclaw.sandboxBrowser": "1" },
});

View File

@ -88,6 +88,9 @@ export function resolveSandboxBrowserConfig(params: {
}): SandboxBrowserConfig {
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
const globalBrowser = params.globalBrowser;
const binds = [...(globalBrowser?.binds ?? []), ...(agentBrowser?.binds ?? [])];
// Treat `binds: []` as an explicit override, so it can disable `docker.binds` for the browser container.
const bindsConfigured = globalBrowser?.binds !== undefined || agentBrowser?.binds !== undefined;
return {
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
@ -107,6 +110,7 @@ export function resolveSandboxBrowserConfig(params: {
agentBrowser?.autoStartTimeoutMs ??
globalBrowser?.autoStartTimeoutMs ??
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
binds: bindsConfigured ? binds : undefined,
};
}

View File

@ -40,6 +40,7 @@ export type SandboxBrowserConfig = {
allowHostControl: boolean;
autoStart: boolean;
autoStartTimeoutMs: number;
binds?: string[];
};
export type SandboxPruneConfig = {

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js";
import { validateConfigObject } from "./config.js";
describe("sandbox docker config", () => {
@ -52,3 +53,83 @@ describe("sandbox docker config", () => {
expect(res.ok).toBe(false);
});
});
describe("sandbox browser binds config", () => {
it("accepts binds array in sandbox.browser config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
browser: {
binds: ["/home/user/.chrome-profile:/data/chrome:rw"],
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.browser?.binds).toEqual([
"/home/user/.chrome-profile:/data/chrome:rw",
]);
}
});
it("rejects non-string values in browser binds array", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
browser: {
binds: [123],
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("merges global and agent browser binds", () => {
const resolved = resolveSandboxBrowserConfig({
scope: "agent",
globalBrowser: { binds: ["/global:/global:ro"] },
agentBrowser: { binds: ["/agent:/agent:rw"] },
});
expect(resolved.binds).toEqual(["/global:/global:ro", "/agent:/agent:rw"]);
});
it("treats empty binds as configured (override to none)", () => {
const resolved = resolveSandboxBrowserConfig({
scope: "agent",
globalBrowser: { binds: [] },
agentBrowser: {},
});
expect(resolved.binds).toEqual([]);
});
it("ignores agent browser binds under shared scope", () => {
const resolved = resolveSandboxBrowserConfig({
scope: "shared",
globalBrowser: { binds: ["/global:/global:ro"] },
agentBrowser: { binds: ["/agent:/agent:rw"] },
});
expect(resolved.binds).toEqual(["/global:/global:ro"]);
const resolvedNoGlobal = resolveSandboxBrowserConfig({
scope: "shared",
globalBrowser: {},
agentBrowser: { binds: ["/agent:/agent:rw"] },
});
expect(resolvedNoGlobal.binds).toBeUndefined();
});
it("returns undefined binds when none configured", () => {
const resolved = resolveSandboxBrowserConfig({
scope: "agent",
globalBrowser: {},
agentBrowser: {},
});
expect(resolved.binds).toBeUndefined();
});
});

View File

@ -65,6 +65,8 @@ export type SandboxBrowserSettings = {
autoStart?: boolean;
/** Max time to wait for CDP to become reachable after auto-start (ms). */
autoStartTimeoutMs?: number;
/** Additional bind mounts for the browser container only. When set, replaces docker.binds for the browser container. */
binds?: string[];
};
export type SandboxPruneSettings = {

View File

@ -139,6 +139,7 @@ export const SandboxBrowserSchema = z
allowHostControl: z.boolean().optional(),
autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(),
binds: z.array(z.string()).optional(),
})
.strict()
.optional();