diff --git a/CHANGELOG.md b/CHANGELOG.md index 82209923a72..36ed547a2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 525c49cfc00..5f49fe92037 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 45062ea9dfb..fe653e82d2a 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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 diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 6e3c9348d80..9e13eb485e6 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -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" }, }); diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index 9619ccd9053..6a012c1db36 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -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, }; } diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 72d08fba316..f667941e39d 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -40,6 +40,7 @@ export type SandboxBrowserConfig = { allowHostControl: boolean; autoStart: boolean; autoStartTimeoutMs: number; + binds?: string[]; }; export type SandboxPruneConfig = { diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 9f3d66e1571..92903ff32f7 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -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(); + }); +}); diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 4f5f8381008..4f05df8d152 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -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 = { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 8190c5bded9..4191d916562 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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();