feat(sandbox): add Docker GPU passthrough

Add opt-in `sandbox.docker.gpus` config plumbing for Docker sandbox containers.

- thread the optional GPU passthrough field through config types, schema, resolution, and Docker create args
- reject empty config values and emit `--gpus` as a separate Docker argv pair
- document the Docker-only behavior and credit the original contributor in the changelog

Fixes #57976.
Carries forward #58124 from @cyan-ember.

Co-authored-by: cyan-ember <5855097+cyan-ember@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-04-28 03:33:28 -07:00
committed by GitHub
parent 7150acba69
commit d70191f8af
11 changed files with 88 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Sandbox/Docker: add opt-in `sandbox.docker.gpus` passthrough for Docker sandbox containers so local GPU workloads can run inside sandboxed agents when the host Docker runtime supports `--gpus`. Fixes #57976; carries forward #58124. Thanks @cyan-ember.
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
- Android: publish authenticated `node.presence.alive` events after node connect and background transitions so paired Android nodes retain durable last-seen metadata after disconnects. Carries forward #63123. Thanks @ngutman.
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.

View File

@@ -89,6 +89,8 @@ SSH-specific config lives under `agents.defaults.sandbox.ssh`. OpenShell-specifi
Sandboxing is off by default. If you enable sandboxing and do not choose a backend, OpenClaw uses the Docker backend. It executes tools and sandbox browsers locally via the Docker daemon socket (`/var/run/docker.sock`). Sandbox container isolation is determined by Docker namespaces.
To expose host GPUs to Docker sandboxes, set `agents.defaults.sandbox.docker.gpus` or the per-agent `agents.list[].sandbox.docker.gpus` override. The value is passed to Docker's `--gpus` flag as a separate argument, for example `"all"` or `"device=GPU-uuid"`, and requires a compatible host runtime such as NVIDIA Container Toolkit.
<Warning>
**Docker-out-of-Docker (DooD) constraints**

View File

@@ -164,6 +164,21 @@ describe("buildSandboxCreateArgs", () => {
);
});
it("emits Docker GPU passthrough as a separate argument", () => {
const cfg = createSandboxConfig({
gpus: "device=GPU-123",
});
const args = buildSandboxCreateArgs({
name: "openclaw-sbx-gpu",
cfg,
scopeKey: "main",
createdAtMs: 1700000000000,
});
expect(args).toEqual(expect.arrayContaining(["--gpus", "device=GPU-123"]));
});
it("emits -v flags for safe custom binds", () => {
const cfg: SandboxDockerConfig = {
image: "openclaw-sandbox:bookworm-slim",

View File

@@ -36,6 +36,29 @@ describe("sandbox config merges", () => {
});
});
it("resolves sandbox docker GPU passthrough with agent precedence", () => {
const inherited = resolveSandboxDockerConfig({
scope: "agent",
globalDocker: { gpus: "all" },
agentDocker: {},
});
expect(inherited.gpus).toBe("all");
const overridden = resolveSandboxDockerConfig({
scope: "agent",
globalDocker: { gpus: "all" },
agentDocker: { gpus: "device=GPU-123" },
});
expect(overridden.gpus).toBe("device=GPU-123");
const sharedScope = resolveSandboxDockerConfig({
scope: "shared",
globalDocker: { gpus: "all" },
agentDocker: { gpus: "device=GPU-123" },
});
expect(sharedScope.gpus).toBe("all");
});
it("resolves docker binds and shared-scope override behavior", () => {
for (const scenario of [
{

View File

@@ -116,6 +116,7 @@ export function resolveSandboxDockerConfig(params: {
memory: agentDocker?.memory ?? globalDocker?.memory,
memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap,
cpus: agentDocker?.cpus ?? globalDocker?.cpus,
gpus: normalizeOptionalString(agentDocker?.gpus ?? globalDocker?.gpus),
ulimits,
seccompProfile: agentDocker?.seccompProfile ?? globalDocker?.seccompProfile,
apparmorProfile: agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile,

View File

@@ -444,6 +444,10 @@ export function buildSandboxCreateArgs(params: {
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
args.push("--cpus", String(params.cfg.cpus));
}
const gpus = params.cfg.gpus?.trim();
if (gpus) {
args.push("--gpus", gpus);
}
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) {
const formatted = formatUlimitValue(name, value);
if (formatted) {

View File

@@ -62,6 +62,39 @@ describe("sandbox docker config", () => {
}
});
it("accepts non-empty Docker GPU passthrough config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
gpus: "all",
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.docker?.gpus).toBe("all");
}
});
it("rejects empty Docker GPU passthrough config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
gpus: "",
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("rejects network host mode via Zod schema validation", () => {
const res = validateConfigObject({
agents: {

View File

@@ -422,6 +422,10 @@ export const FIELD_HELP: Record<string, string> = {
"DANGEROUS break-glass override that allows sandbox Docker network mode container:<id>. This joins another container namespace and weakens sandbox isolation.",
"agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin":
"Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.",
"agents.defaults.sandbox.docker.gpus":
'Optional Docker GPU passthrough value passed to --gpus, for example "all" or "device=GPU-uuid". Requires a compatible host runtime such as NVIDIA Container Toolkit.',
"agents.list[].sandbox.docker.gpus":
"Per-agent Docker GPU passthrough override for sandbox containers.",
"agents.defaults.sandbox.browser.cdpSourceRange":
"Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).",
"agents.list[].sandbox.browser.cdpSourceRange":

View File

@@ -635,6 +635,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range",
"agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin":
"Sandbox Docker Allow Container Namespace Join",
"agents.defaults.sandbox.docker.gpus": "Sandbox Docker GPUs",
commands: "Commands",
"commands.native": "Native Commands",
"commands.nativeSkills": "Native Skill Commands",
@@ -879,6 +880,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range",
"agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin":
"Agent Sandbox Docker Allow Container Namespace Join",
"agents.list[].sandbox.docker.gpus": "Agent Sandbox Docker GPUs",
"discovery.mdns.mode": "mDNS Discovery Mode",
plugins: "Plugins",
"plugins.enabled": "Enable Plugins",

View File

@@ -29,6 +29,8 @@ export type SandboxDockerSettings = {
memorySwap?: string | number;
/** Limit container CPU shares (e.g. 0.5, 1, 2). */
cpus?: number;
/** GPU devices to expose via Docker --gpus (e.g. "all", "device=GPU-uuid"). */
gpus?: string;
/**
* Set ulimit values by name (e.g. nofile, nproc).
* Use "soft:hard" string, a number, or { soft, hard }.

View File

@@ -119,6 +119,7 @@ export const SandboxDockerSchema = z
memory: z.union([z.string(), z.number()]).optional(),
memorySwap: z.union([z.string(), z.number()]).optional(),
cpus: z.number().positive().optional(),
gpus: z.string().min(1).optional(),
ulimits: z
.record(
z.string(),