Merge pull request #790 from akonyer/feature/custom-sandbox-binds
Custom sandbox binds for docker sandbox
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
### Fixes
|
||||||
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
|
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
|
||||||
|
|||||||
@@ -1606,7 +1606,8 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
|||||||
seccompProfile: "/path/to/seccomp.json",
|
seccompProfile: "/path/to/seccomp.json",
|
||||||
apparmorProfile: "clawdbot-sandbox",
|
apparmorProfile: "clawdbot-sandbox",
|
||||||
dns: ["1.1.1.1", "8.8.8.8"],
|
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: {
|
browser: {
|
||||||
enabled: false,
|
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: 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:
|
Build the optional browser image with:
|
||||||
```bash
|
```bash
|
||||||
scripts/sandbox-browser-setup.sh
|
scripts/sandbox-browser-setup.sh
|
||||||
|
|||||||
@@ -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
|
they can be read. With `"rw"`, workspace skills are readable from
|
||||||
`/workspace/skills`.
|
`/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
|
## Images + setup
|
||||||
Default image: `clawdbot-sandbox:bookworm-slim`
|
Default image: `clawdbot-sandbox:bookworm-slim`
|
||||||
|
|
||||||
|
|||||||
@@ -91,4 +91,70 @@ describe("buildSandboxCreateArgs", () => {
|
|||||||
expect.arrayContaining(["nofile=1024:2048", "nproc=128", "core=0"]),
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
it("ignores agent docker overrides under shared scope", async () => {
|
||||||
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
|
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export type SandboxDockerConfig = {
|
|||||||
apparmorProfile?: string;
|
apparmorProfile?: string;
|
||||||
dns?: string[];
|
dns?: string[];
|
||||||
extraHosts?: string[];
|
extraHosts?: string[];
|
||||||
|
binds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxPruneConfig = {
|
export type SandboxPruneConfig = {
|
||||||
@@ -325,6 +326,8 @@ export function resolveSandboxDockerConfig(params: {
|
|||||||
? { ...globalDocker?.ulimits, ...agentDocker.ulimits }
|
? { ...globalDocker?.ulimits, ...agentDocker.ulimits }
|
||||||
: globalDocker?.ulimits;
|
: globalDocker?.ulimits;
|
||||||
|
|
||||||
|
const binds = [...(globalDocker?.binds ?? []), ...(agentDocker?.binds ?? [])];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE,
|
image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE,
|
||||||
containerPrefix:
|
containerPrefix:
|
||||||
@@ -352,6 +355,7 @@ export function resolveSandboxDockerConfig(params: {
|
|||||||
agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile,
|
agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile,
|
||||||
dns: agentDocker?.dns ?? globalDocker?.dns,
|
dns: agentDocker?.dns ?? globalDocker?.dns,
|
||||||
extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts,
|
extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts,
|
||||||
|
binds: binds.length ? binds : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,6 +1020,11 @@ export function buildSandboxCreateArgs(params: {
|
|||||||
const formatted = formatUlimitValue(name, value);
|
const formatted = formatUlimitValue(name, value);
|
||||||
if (formatted) args.push("--ulimit", formatted);
|
if (formatted) args.push("--ulimit", formatted);
|
||||||
}
|
}
|
||||||
|
if (params.cfg.binds?.length) {
|
||||||
|
for (const bind of params.cfg.binds) {
|
||||||
|
args.push("-v", bind);
|
||||||
|
}
|
||||||
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -922,6 +922,8 @@ export type SandboxDockerSettings = {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */
|
/** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */
|
||||||
extraHosts?: string[];
|
extraHosts?: string[];
|
||||||
|
/** Additional bind mounts (host:container:mode format, e.g. ["/host/path:/container/path:rw"]). */
|
||||||
|
binds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxBrowserSettings = {
|
export type SandboxBrowserSettings = {
|
||||||
|
|||||||
@@ -241,6 +241,26 @@ const ExecutableTokenSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.refine(isSafeExecutableValue, "expected safe executable name or path");
|
.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
|
const ToolsAudioTranscriptionSchema = z
|
||||||
.object({
|
.object({
|
||||||
args: z.array(z.string()).optional(),
|
args: z.array(z.string()).optional(),
|
||||||
@@ -256,14 +276,6 @@ const TelegramTopicSchema = z.object({
|
|||||||
systemPrompt: z.string().optional(),
|
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({
|
const TelegramGroupSchema = z.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
@@ -720,18 +732,6 @@ const MessagesSchema = z
|
|||||||
})
|
})
|
||||||
.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 HeartbeatSchema = z
|
const HeartbeatSchema = z
|
||||||
.object({
|
.object({
|
||||||
every: z.string().optional(),
|
every: z.string().optional(),
|
||||||
@@ -801,6 +801,7 @@ const SandboxDockerSchema = z
|
|||||||
apparmorProfile: z.string().optional(),
|
apparmorProfile: z.string().optional(),
|
||||||
dns: z.array(z.string()).optional(),
|
dns: z.array(z.string()).optional(),
|
||||||
extraHosts: z.array(z.string()).optional(),
|
extraHosts: z.array(z.string()).optional(),
|
||||||
|
binds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user