mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
* feat(docker): add opt-in sandbox support for Docker deployments Enable Docker-based sandbox isolation via OPENCLAW_SANDBOX=1 env var in docker-setup.sh. This is a prerequisite for agents.defaults.sandbox to function in any Docker deployment (self-hosted, Hostinger, DigitalOcean). Changes: - Dockerfile: add OPENCLAW_INSTALL_DOCKER_CLI build arg (~50MB, opt-in) - docker-compose.yml: add commented-out docker.sock mount with docs - docker-setup.sh: auto-detect Docker socket, inject mount, detect GID, build sandbox image, configure sandbox defaults, add group_add All changes are opt-in. Zero impact on existing deployments. Usage: OPENCLAW_SANDBOX=1 ./docker-setup.sh Closes #29933 Related: #7575, #7827, #28401, #10361, #12505, #28326 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review feedback on sandbox support - Persist OPENCLAW_SANDBOX, DOCKER_GID, OPENCLAW_INSTALL_DOCKER_CLI to .env via upsert_env so group_add survives re-runs - Show config set errors instead of swallowing them silently; report partial failure when sandbox config is incomplete - Warn when Dockerfile.sandbox is missing but sandbox config is still applied (sandbox image won't exist) - Fix non-canonical whitespace in apt sources.list entry by using printf instead of echo with line continuation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove `local` outside function and guard sandbox behind Docker CLI check - Remove `local` keyword from top-level `sandbox_config_ok` assignment which caused script exit under `set -euo pipefail` (bash `local` outside a function is an error) - Add Docker CLI prerequisite check for pre-built (non-local) images: runs `docker --version` inside the container and skips sandbox setup with a clear warning if the CLI is missing - Split sandbox block so config is only applied after prerequisites pass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: defer docker.sock mount until sandbox prerequisites pass Move Docker socket mounting from the early setup phase (before image build/pull) to a dedicated compose overlay created only after: 1. Docker CLI is verified inside the container image 2. /var/run/docker.sock exists on the host Previously the socket was mounted optimistically at startup, leaving the host Docker daemon exposed even when sandbox setup was later skipped due to missing Docker CLI. Now the gateway starts without the socket, and a docker-compose.sandbox.yml overlay is generated only when all prerequisites pass. The gateway restart at the end of sandbox setup picks up both the socket mount and sandbox config. Also moves group_add from write_extra_compose() into the sandbox overlay, keeping all sandbox-specific compose configuration together. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(docker): fix sandbox docs URL in setup output * Docker: harden sandbox setup fallback behavior * Tests: cover docker-setup sandbox edge paths * Docker: roll back sandbox mode on partial config failure * Tests: assert sandbox mode rollback on partial setup * Docs: document Docker sandbox bootstrap env controls * Changelog: credit Docker sandbox bootstrap hardening * Update CHANGELOG.md * Docker: verify Docker apt signing key fingerprint * Docker: avoid sandbox overlay deps during policy writes * Tests: assert no-deps sandbox rollback gateway recreate * Docs: mention OPENCLAW_INSTALL_DOCKER_CLI in Docker env vars --------- Co-authored-by: Jakub Karwowski <jakubkarwowski@Mac.lan> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
|
|
type DockerSetupSandbox = {
|
|
rootDir: string;
|
|
scriptPath: string;
|
|
logPath: string;
|
|
binDir: string;
|
|
};
|
|
|
|
async function writeDockerStub(binDir: string, logPath: string) {
|
|
const stub = `#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
log="$DOCKER_STUB_LOG"
|
|
fail_match="\${DOCKER_STUB_FAIL_MATCH:-}"
|
|
if [[ "\${1:-}" == "compose" && "\${2:-}" == "version" ]]; then
|
|
exit 0
|
|
fi
|
|
if [[ "\${1:-}" == "build" ]]; then
|
|
if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then
|
|
echo "build-fail $*" >>"$log"
|
|
exit 1
|
|
fi
|
|
echo "build $*" >>"$log"
|
|
exit 0
|
|
fi
|
|
if [[ "\${1:-}" == "compose" ]]; then
|
|
if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then
|
|
echo "compose-fail $*" >>"$log"
|
|
exit 1
|
|
fi
|
|
echo "compose $*" >>"$log"
|
|
exit 0
|
|
fi
|
|
echo "unknown $*" >>"$log"
|
|
exit 0
|
|
`;
|
|
|
|
await mkdir(binDir, { recursive: true });
|
|
await writeFile(join(binDir, "docker"), stub, { mode: 0o755 });
|
|
await writeFile(logPath, "");
|
|
}
|
|
|
|
async function createDockerSetupSandbox(): Promise<DockerSetupSandbox> {
|
|
const rootDir = await mkdtemp(join(tmpdir(), "openclaw-docker-setup-"));
|
|
const scriptPath = join(rootDir, "docker-setup.sh");
|
|
const dockerfilePath = join(rootDir, "Dockerfile");
|
|
const composePath = join(rootDir, "docker-compose.yml");
|
|
const binDir = join(rootDir, "bin");
|
|
const logPath = join(rootDir, "docker-stub.log");
|
|
|
|
await copyFile(join(repoRoot, "docker-setup.sh"), scriptPath);
|
|
await chmod(scriptPath, 0o755);
|
|
await writeFile(dockerfilePath, "FROM scratch\n");
|
|
await writeFile(
|
|
composePath,
|
|
"services:\n openclaw-gateway:\n image: noop\n openclaw-cli:\n image: noop\n",
|
|
);
|
|
await writeDockerStub(binDir, logPath);
|
|
|
|
return { rootDir, scriptPath, logPath, binDir };
|
|
}
|
|
|
|
function createEnv(
|
|
sandbox: DockerSetupSandbox,
|
|
overrides: Record<string, string | undefined> = {},
|
|
): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {
|
|
PATH: `${sandbox.binDir}:${process.env.PATH ?? ""}`,
|
|
HOME: process.env.HOME ?? sandbox.rootDir,
|
|
LANG: process.env.LANG,
|
|
LC_ALL: process.env.LC_ALL,
|
|
TMPDIR: process.env.TMPDIR,
|
|
DOCKER_STUB_LOG: sandbox.logPath,
|
|
OPENCLAW_GATEWAY_TOKEN: "test-token",
|
|
OPENCLAW_CONFIG_DIR: join(sandbox.rootDir, "config"),
|
|
OPENCLAW_WORKSPACE_DIR: join(sandbox.rootDir, "openclaw"),
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
if (value === undefined) {
|
|
delete env[key];
|
|
} else {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function requireSandbox(sandbox: DockerSetupSandbox | null): DockerSetupSandbox {
|
|
if (!sandbox) {
|
|
throw new Error("sandbox missing");
|
|
}
|
|
return sandbox;
|
|
}
|
|
|
|
function runDockerSetup(
|
|
sandbox: DockerSetupSandbox,
|
|
overrides: Record<string, string | undefined> = {},
|
|
) {
|
|
return spawnSync("bash", [sandbox.scriptPath], {
|
|
cwd: sandbox.rootDir,
|
|
env: createEnv(sandbox, overrides),
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
});
|
|
}
|
|
|
|
async function withUnixSocket<T>(socketPath: string, run: () => Promise<T>): Promise<T> {
|
|
const server = createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (error: Error) => {
|
|
server.off("listening", onListening);
|
|
reject(error);
|
|
};
|
|
const onListening = () => {
|
|
server.off("error", onError);
|
|
resolve();
|
|
};
|
|
server.once("error", onError);
|
|
server.once("listening", onListening);
|
|
server.listen(socketPath);
|
|
});
|
|
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
await rm(socketPath, { force: true });
|
|
}
|
|
}
|
|
|
|
function resolveBashForCompatCheck(): string | null {
|
|
for (const candidate of ["/bin/bash", "bash"]) {
|
|
const probe = spawnSync(candidate, ["-c", "exit 0"], { encoding: "utf8" });
|
|
if (!probe.error && probe.status === 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
describe("docker-setup.sh", () => {
|
|
let sandbox: DockerSetupSandbox | null = null;
|
|
|
|
beforeAll(async () => {
|
|
sandbox = await createDockerSetupSandbox();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!sandbox) {
|
|
return;
|
|
}
|
|
await rm(sandbox.rootDir, { recursive: true, force: true });
|
|
sandbox = null;
|
|
});
|
|
|
|
it("handles env defaults, home-volume mounts, and apt build args", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential",
|
|
OPENCLAW_EXTRA_MOUNTS: undefined,
|
|
OPENCLAW_HOME_VOLUME: "openclaw-home",
|
|
});
|
|
expect(result.status).toBe(0);
|
|
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
|
expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential");
|
|
expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS=");
|
|
expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home");
|
|
const extraCompose = await readFile(
|
|
join(activeSandbox.rootDir, "docker-compose.extra.yml"),
|
|
"utf8",
|
|
);
|
|
expect(extraCompose).toContain("openclaw-home:/home/node");
|
|
expect(extraCompose).toContain("volumes:");
|
|
expect(extraCompose).toContain("openclaw-home:");
|
|
const log = await readFile(activeSandbox.logPath, "utf8");
|
|
expect(log).toContain("--build-arg OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential");
|
|
expect(log).toContain("run --rm openclaw-cli onboard --mode local --no-install-daemon");
|
|
expect(log).toContain("run --rm openclaw-cli config set gateway.mode local");
|
|
expect(log).toContain("run --rm openclaw-cli config set gateway.bind lan");
|
|
});
|
|
|
|
it("precreates config identity dir for CLI device auth writes", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
const configDir = join(activeSandbox.rootDir, "config-identity");
|
|
const workspaceDir = join(activeSandbox.rootDir, "workspace-identity");
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_CONFIG_DIR: configDir,
|
|
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const identityDirStat = await stat(join(configDir, "identity"));
|
|
expect(identityDirStat.isDirectory()).toBe(true);
|
|
});
|
|
|
|
it("precreates agent data dirs to avoid EACCES in container", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
const configDir = join(activeSandbox.rootDir, "config-agent-dirs");
|
|
const workspaceDir = join(activeSandbox.rootDir, "workspace-agent-dirs");
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_CONFIG_DIR: configDir,
|
|
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const agentDirStat = await stat(join(configDir, "agents", "main", "agent"));
|
|
expect(agentDirStat.isDirectory()).toBe(true);
|
|
const sessionsDirStat = await stat(join(configDir, "agents", "main", "sessions"));
|
|
expect(sessionsDirStat.isDirectory()).toBe(true);
|
|
|
|
// Verify that a root-user chown step runs before onboarding.
|
|
const log = await readFile(activeSandbox.logPath, "utf8");
|
|
const chownIdx = log.indexOf("--user root");
|
|
const onboardIdx = log.indexOf("onboard");
|
|
expect(chownIdx).toBeGreaterThanOrEqual(0);
|
|
expect(onboardIdx).toBeGreaterThan(chownIdx);
|
|
});
|
|
|
|
it("reuses existing config token when OPENCLAW_GATEWAY_TOKEN is unset", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
const configDir = join(activeSandbox.rootDir, "config-token-reuse");
|
|
const workspaceDir = join(activeSandbox.rootDir, "workspace-token-reuse");
|
|
await mkdir(configDir, { recursive: true });
|
|
await writeFile(
|
|
join(configDir, "openclaw.json"),
|
|
JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }),
|
|
);
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_GATEWAY_TOKEN: undefined,
|
|
OPENCLAW_CONFIG_DIR: configDir,
|
|
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
|
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123");
|
|
});
|
|
|
|
it("treats OPENCLAW_SANDBOX=0 as disabled", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
await writeFile(activeSandbox.logPath, "");
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_SANDBOX: "0",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
|
expect(envFile).toContain("OPENCLAW_SANDBOX=");
|
|
|
|
const log = await readFile(activeSandbox.logPath, "utf8");
|
|
expect(log).toContain("--build-arg OPENCLAW_INSTALL_DOCKER_CLI=");
|
|
expect(log).not.toContain("--build-arg OPENCLAW_INSTALL_DOCKER_CLI=1");
|
|
expect(log).toContain("config set agents.defaults.sandbox.mode off");
|
|
});
|
|
|
|
it("resets stale sandbox mode and overlay when sandbox is not active", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
await writeFile(activeSandbox.logPath, "");
|
|
await writeFile(
|
|
join(activeSandbox.rootDir, "docker-compose.sandbox.yml"),
|
|
"services:\n openclaw-gateway:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n",
|
|
);
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_SANDBOX: "1",
|
|
DOCKER_STUB_FAIL_MATCH: "--entrypoint docker openclaw-gateway --version",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("Sandbox requires Docker CLI");
|
|
const log = await readFile(activeSandbox.logPath, "utf8");
|
|
expect(log).toContain("config set agents.defaults.sandbox.mode off");
|
|
await expect(stat(join(activeSandbox.rootDir, "docker-compose.sandbox.yml"))).rejects.toThrow();
|
|
});
|
|
|
|
it("skips sandbox gateway restart when sandbox config writes fail", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
await writeFile(activeSandbox.logPath, "");
|
|
const socketPath = join(activeSandbox.rootDir, "sandbox.sock");
|
|
|
|
await withUnixSocket(socketPath, async () => {
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_SANDBOX: "1",
|
|
OPENCLAW_DOCKER_SOCKET: socketPath,
|
|
DOCKER_STUB_FAIL_MATCH: "config set agents.defaults.sandbox.scope",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("Failed to set agents.defaults.sandbox.scope");
|
|
expect(result.stderr).toContain("Skipping gateway restart to avoid exposing Docker socket");
|
|
|
|
const log = await readFile(activeSandbox.logPath, "utf8");
|
|
const gatewayStarts = log
|
|
.split("\n")
|
|
.filter(
|
|
(line) =>
|
|
line.includes("compose") &&
|
|
line.includes(" up -d") &&
|
|
line.includes("openclaw-gateway"),
|
|
);
|
|
expect(gatewayStarts).toHaveLength(2);
|
|
expect(log).toContain(
|
|
"run --rm --no-deps openclaw-cli config set agents.defaults.sandbox.mode non-main",
|
|
);
|
|
expect(log).toContain("config set agents.defaults.sandbox.mode off");
|
|
const forceRecreateLine = log
|
|
.split("\n")
|
|
.find((line) => line.includes("up -d --force-recreate openclaw-gateway"));
|
|
expect(forceRecreateLine).toBeDefined();
|
|
expect(forceRecreateLine).not.toContain("docker-compose.sandbox.yml");
|
|
await expect(
|
|
stat(join(activeSandbox.rootDir, "docker-compose.sandbox.yml")),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it("rejects injected multiline OPENCLAW_EXTRA_MOUNTS values", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_EXTRA_MOUNTS: "/tmp:/tmp\n evil-service:\n image: alpine",
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("OPENCLAW_EXTRA_MOUNTS cannot contain control characters");
|
|
});
|
|
|
|
it("rejects invalid OPENCLAW_EXTRA_MOUNTS mount format", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_EXTRA_MOUNTS: "bad mount spec",
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("Invalid mount format");
|
|
});
|
|
|
|
it("rejects invalid OPENCLAW_HOME_VOLUME names", async () => {
|
|
const activeSandbox = requireSandbox(sandbox);
|
|
|
|
const result = runDockerSetup(activeSandbox, {
|
|
OPENCLAW_HOME_VOLUME: "bad name",
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("OPENCLAW_HOME_VOLUME must match");
|
|
});
|
|
|
|
it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => {
|
|
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
|
|
expect(script).not.toMatch(/^\s*declare -A\b/m);
|
|
|
|
const systemBash = resolveBashForCompatCheck();
|
|
if (!systemBash) {
|
|
return;
|
|
}
|
|
|
|
const assocCheck = spawnSync(systemBash, ["-c", "declare -A _t=()"], {
|
|
encoding: "utf8",
|
|
});
|
|
if (assocCheck.status === 0 || assocCheck.status === null) {
|
|
// Skip runtime check when system bash supports associative arrays
|
|
// (not Bash 3.2) or when /bin/bash is unavailable (e.g. Windows).
|
|
return;
|
|
}
|
|
|
|
const syntaxCheck = spawnSync(systemBash, ["-n", join(repoRoot, "docker-setup.sh")], {
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(syntaxCheck.status).toBe(0);
|
|
expect(syntaxCheck.stderr).not.toContain("declare: -A: invalid option");
|
|
});
|
|
|
|
it("keeps docker-compose gateway command in sync", async () => {
|
|
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
|
|
expect(compose).not.toContain("gateway-daemon");
|
|
expect(compose).toContain('"gateway"');
|
|
});
|
|
|
|
it("keeps docker-compose CLI network namespace settings in sync", async () => {
|
|
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
|
|
expect(compose).toContain('network_mode: "service:openclaw-gateway"');
|
|
expect(compose).toContain("depends_on:\n - openclaw-gateway");
|
|
});
|
|
});
|