diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be30c218..656ab4918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker. - CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary. - Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated. +- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer) ### Fixes - Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 33e0983a3..8e6c2fe5c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1606,7 +1606,8 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, seccompProfile: "/path/to/seccomp.json", apparmorProfile: "clawdbot-sandbox", dns: ["1.1.1.1", "8.8.8.8"], - extraHosts: ["internal.service:10.0.0.5"] + extraHosts: ["internal.service:10.0.0.5"], + binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"] }, browser: { enabled: false, @@ -1652,6 +1653,8 @@ to `"bridge"` (or your custom network) if the agent needs outbound access. Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace. +Note: `docker.binds` mounts additional host directories; global and per-agent binds are merged. + Build the optional browser image with: ```bash scripts/sandbox-browser-setup.sh diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 8aec4a747..4411300ee 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -56,6 +56,12 @@ Clawdbot mirrors eligible skills into the sandbox workspace (`.../skills`) so they can be read. With `"rw"`, workspace skills are readable from `/workspace/skills`. +## Custom bind mounts +`agents.defaults.sandbox.docker.binds` mounts additional host directories into the container. +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. + ## Images + setup Default image: `clawdbot-sandbox:bookworm-slim` diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index ebe7385c4..8e468fc3f 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -91,4 +91,70 @@ describe("buildSandboxCreateArgs", () => { expect.arrayContaining(["nofile=1024:2048", "nproc=128", "core=0"]), ); }); + + it("emits -v flags for custom binds", () => { + const cfg: SandboxDockerConfig = { + image: "clawdbot-sandbox:bookworm-slim", + containerPrefix: "clawdbot-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: [ + "/home/user/source:/source:rw", + "/var/run/docker.sock:/var/run/docker.sock", + ], + }; + + const args = buildSandboxCreateArgs({ + name: "clawdbot-sbx-binds", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }); + + expect(args).toContain("-v"); + const vFlags: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "-v") { + const value = args[i + 1]; + if (value) vFlags.push(value); + } + } + expect(vFlags).toContain("/home/user/source:/source:rw"); + expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock"); + }); + + it("omits -v flags when binds is empty or undefined", () => { + const cfg: SandboxDockerConfig = { + image: "clawdbot-sandbox:bookworm-slim", + containerPrefix: "clawdbot-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: [], + }; + + const args = buildSandboxCreateArgs({ + name: "clawdbot-sbx-no-binds", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }); + + // Count -v flags that are NOT workspace mounts (workspace mounts are internal) + const customVFlags: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "-v") { + const value = args[i + 1]; + if (value && !value.includes("/workspace")) { + customVFlags.push(value); + } + } + } + expect(customVFlags).toHaveLength(0); + }); }); diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 042a0ff96..bf0bfd004 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -38,6 +38,55 @@ describe("sandbox config merges", () => { }); }); + it("merges sandbox docker binds (global + agent combined)", async () => { + const { resolveSandboxDockerConfig } = await import("./sandbox.js"); + + const resolved = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { + binds: ["/var/run/docker.sock:/var/run/docker.sock"], + }, + agentDocker: { + binds: ["/home/user/source:/source:rw"], + }, + }); + + expect(resolved.binds).toEqual([ + "/var/run/docker.sock:/var/run/docker.sock", + "/home/user/source:/source:rw", + ]); + }); + + it("returns undefined binds when neither global nor agent has binds", async () => { + const { resolveSandboxDockerConfig } = await import("./sandbox.js"); + + const resolved = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: {}, + agentDocker: {}, + }); + + expect(resolved.binds).toBeUndefined(); + }); + + it("ignores agent binds under shared scope", async () => { + const { resolveSandboxDockerConfig } = await import("./sandbox.js"); + + const resolved = resolveSandboxDockerConfig({ + scope: "shared", + globalDocker: { + binds: ["/var/run/docker.sock:/var/run/docker.sock"], + }, + agentDocker: { + binds: ["/home/user/source:/source:rw"], + }, + }); + + expect(resolved.binds).toEqual([ + "/var/run/docker.sock:/var/run/docker.sock", + ]); + }); + it("ignores agent docker overrides under shared scope", async () => { const { resolveSandboxDockerConfig } = await import("./sandbox.js"); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 62bba4ff3..5ca68ed61 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -107,6 +107,7 @@ export type SandboxDockerConfig = { apparmorProfile?: string; dns?: string[]; extraHosts?: string[]; + binds?: string[]; }; export type SandboxPruneConfig = { @@ -325,6 +326,8 @@ export function resolveSandboxDockerConfig(params: { ? { ...globalDocker?.ulimits, ...agentDocker.ulimits } : globalDocker?.ulimits; + const binds = [...(globalDocker?.binds ?? []), ...(agentDocker?.binds ?? [])]; + return { image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE, containerPrefix: @@ -352,6 +355,7 @@ export function resolveSandboxDockerConfig(params: { agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile, dns: agentDocker?.dns ?? globalDocker?.dns, extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, + binds: binds.length ? binds : undefined, }; } @@ -1016,6 +1020,11 @@ export function buildSandboxCreateArgs(params: { const formatted = formatUlimitValue(name, value); if (formatted) args.push("--ulimit", formatted); } + if (params.cfg.binds?.length) { + for (const bind of params.cfg.binds) { + args.push("-v", bind); + } + } return args; } diff --git a/src/config/types.ts b/src/config/types.ts index e5d00545d..83e161490 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -922,6 +922,8 @@ export type SandboxDockerSettings = { dns?: string[]; /** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */ extraHosts?: string[]; + /** Additional bind mounts (host:container:mode format, e.g. ["/host/path:/container/path:rw"]). */ + binds?: string[]; }; export type SandboxBrowserSettings = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8c1450750..e92329441 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -241,6 +241,26 @@ const ExecutableTokenSchema = z .string() .refine(isSafeExecutableValue, "expected safe executable name or path"); +const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]); + +const ProviderCommandsSchema = z + .object({ + native: NativeCommandsSettingSchema.optional(), + }) + .optional(); + +const CommandsSchema = z + .object({ + native: NativeCommandsSettingSchema.optional().default("auto"), + text: z.boolean().optional(), + config: z.boolean().optional(), + debug: z.boolean().optional(), + restart: z.boolean().optional(), + useAccessGroups: z.boolean().optional(), + }) + .optional() + .default({ native: "auto" }); + const ToolsAudioTranscriptionSchema = z .object({ args: z.array(z.string()).optional(), @@ -256,14 +276,6 @@ const TelegramTopicSchema = z.object({ systemPrompt: z.string().optional(), }); -const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]); - -const ProviderCommandsSchema = z - .object({ - native: NativeCommandsSettingSchema.optional(), - }) - .optional(); - const TelegramGroupSchema = z.object({ requireMention: z.boolean().optional(), skills: z.array(z.string()).optional(), @@ -720,18 +732,6 @@ const MessagesSchema = z }) .optional(); -const CommandsSchema = z - .object({ - native: NativeCommandsSettingSchema.optional().default("auto"), - text: z.boolean().optional(), - config: z.boolean().optional(), - debug: z.boolean().optional(), - restart: z.boolean().optional(), - useAccessGroups: z.boolean().optional(), - }) - .optional() - .default({ native: "auto" }); - const HeartbeatSchema = z .object({ every: z.string().optional(), @@ -801,6 +801,7 @@ const SandboxDockerSchema = z apparmorProfile: z.string().optional(), dns: z.array(z.string()).optional(), extraHosts: z.array(z.string()).optional(), + binds: z.array(z.string()).optional(), }) .optional();