openclaw/test/scripts/docker-setup.test.ts
Peter Steinberger 94cbbecd46
test(ci,gateway,integration): remove low-value tests (batch d151) (#163532)
* test(ci): deslop s1016 tests

* test(gateway): deslop s842 tests

Port s842 commits 77c589897919272a876fe407c3b567a26ee4df11 and
9cccf18820d7eb027e39ed7f22f94a99a29d647f onto the campaign lane.
The abort fixture fixes are already present on the lane.

Consolidate yield setup while preserving HEAD's eight yield/resume cases,
transactional pause, requester retirement, acknowledgement recovery, and
unknown-write reconciliation assertions. Apply the shard's dispatch and
approval replay cleanup to files unchanged since its parent.

Validation: Testbox on origin/main baa26a6ad5
plus all five resolved shard files: 5 files, 25 tests passed, no skips,
82.86s Vitest duration. No timeouts raised. oxlint, oxfmt --check,
git diff --check, and independent review passed.

* test(tooling): deslop s1009 tests

* test(integration): deslop s1007 tests

* test(helpers): deslop s1010 tests

* test(plugins): deslop s1011 tests

* test(integration): deslop s1006 tests

* test(scripts): deslop s1022 tests

* test(scripts): deslop s1021 tests

* test(plugins): deslop s1012 tests
2026-10-02 13:03:08 +00:00

955 lines
39 KiB
TypeScript

// Shell contract tests for Docker setup behavior and generated commands.
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { parse } from "yaml";
import {
cleanupDockerSetupSandboxRoot,
collectMatchingLines,
createDockerSetupSandbox,
expectMissingPath,
expectOfflineComposePolicy,
findGatewayStartLineIndex,
isGatewayStartLine,
noFollowOwnershipRepair,
prestartContainerEnvFlags,
prestartSafePath,
readDockerLog,
readDockerLogLines,
repoRoot,
resetDockerLog,
resolveBashForCompatCheck,
setupDockerSetupSandboxRoot,
withUnixSocket,
type DockerSetupSandbox,
} from "./docker-setup.test-support.js";
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"),
OPENCLAW_AUTH_PROFILE_SECRET_DIR: join(sandbox.rootDir, "auth-profile-secrets"),
};
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) {
delete env[key];
} else {
env[key] = value;
}
}
return env;
}
function runDockerSetup(
sandbox: DockerSetupSandbox,
overrides: Record<string, string | undefined> = {},
args: string[] = [],
) {
return spawnSync("bash", [sandbox.scriptPath, ...args], {
cwd: sandbox.rootDir,
env: createEnv(sandbox, overrides),
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
}
async function runDockerSetupWithUnsetGatewayToken(
sandbox: DockerSetupSandbox,
suffix: string,
prepare?: (configDir: string) => Promise<void>,
) {
const configDir = join(sandbox.rootDir, `config-${suffix}`);
const workspaceDir = join(sandbox.rootDir, `workspace-${suffix}`);
await mkdir(configDir, { recursive: true });
await prepare?.(configDir);
const result = runDockerSetup(sandbox, {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_CONFIG_DIR: configDir,
OPENCLAW_WORKSPACE_DIR: workspaceDir,
});
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
return { result, envFile };
}
describe("scripts/docker/setup.sh", () => {
let sandbox: DockerSetupSandbox;
beforeAll(async () => {
await setupDockerSetupSandboxRoot();
sandbox = await createDockerSetupSandbox();
});
afterAll(async () => {
if (!sandbox) {
await cleanupDockerSetupSandboxRoot();
return;
}
await rm(sandbox.rootDir, { recursive: true, force: true });
await cleanupDockerSetupSandboxRoot();
});
it("handles env defaults, home-volume mounts, and Docker build args", async () => {
const buildCommit = "0123456789abcdef0123456789abcdef01234567";
const result = runDockerSetup(sandbox, {
GIT_COMMIT: buildCommit,
OPENCLAW_DOCKER_APT_PACKAGES: "curl wget",
OPENCLAW_EXTRA_MOUNTS: undefined,
OPENCLAW_HOME_VOLUME: "openclaw-home",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_IMAGE_APT_PACKAGES=curl wget");
expect(envFile).not.toContain("OPENCLAW_DOCKER_APT_PACKAGES");
expect(envFile).toContain("OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=8192");
expect(envFile).toContain("OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=");
expect(envFile).toContain("OPENCLAW_DOCKER_BUILD_SKIP_DTS=1");
expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS=");
expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); // pragma: allowlist secret
expect(envFile).toContain("OPENCLAW_DISABLE_BONJOUR=");
expect(envFile).toContain(
`OPENCLAW_AUTH_PROFILE_SECRET_DIR=${join(sandbox.rootDir, "auth-profile-secrets")}`,
);
const extraCompose = await readFile(join(sandbox.rootDir, "docker-compose.extra.yml"), "utf8");
expect(extraCompose).toContain("openclaw-home:/home/node");
expect(extraCompose).toContain(
`${join(sandbox.rootDir, "auth-profile-secrets")}:/home/node/.config/openclaw`,
);
expect(extraCompose).toContain("volumes:");
expect(extraCompose).toContain("openclaw-home:");
const log = await readDockerLog(sandbox);
expect(log).toContain("--build-arg OPENCLAW_IMAGE_APT_PACKAGES=curl wget");
expect(log).not.toContain("--build-arg OPENCLAW_DOCKER_APT_PACKAGES");
expect(log).toContain(
"--build-arg OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=8192",
);
expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=");
expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_SKIP_DTS=1");
expect(log).toMatch(
/--build-arg OPENCLAW_BUILD_TIMESTAMP=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/u,
);
expect(log).toContain(`--build-arg GIT_COMMIT=${buildCommit}`);
expect(log).toContain(
`run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output`,
);
expect(result.stdout).toContain("Gateway token: stored in Docker environment/config");
expect(result.stdout).toContain("Gateway running with host port mapping.");
expect(result.stdout).toContain("Access from tailnet devices via the host's tailnet IP.");
expect(result.stdout).toContain("Commands:");
expect(result.stdout).toContain("logs -f openclaw-gateway");
expect(result.stdout).toContain(
`exec openclaw-gateway sh -lc 'node dist/index.js gateway health --token "$OPENCLAW_GATEWAY_TOKEN"'`,
);
expect(result.stdout).not.toContain("node dist/index.js health --token");
expect(result.stdout).not.toContain("test-token");
expect(result.stdout).not.toContain("#token=");
expect(log).toContain(
`run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js config set --batch-json [{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"lan"},{"path":"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789"]}]`,
);
expect(log).not.toContain("run --rm openclaw-cli onboard --mode local --no-install-daemon");
});
it.each([undefined, "[]"])(
"keeps inherited origins out of Docker setup writes (%j)",
async (allowedOrigins) => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
DOCKER_STUB_CONTROL_UI_ORIGINS: allowedOrigins,
DOCKER_STUB_PUBLIC_ORIGIN: "https://team.example.com",
});
expect(result.status).toBe(0);
const writes = (await readDockerLogLines(sandbox)).filter((line) =>
line.includes("config set --batch-json"),
);
expect(writes).toHaveLength(1);
expect(writes[0]).not.toContain("https://team.example.com");
if (allowedOrigins === undefined) {
expect(writes[0]).not.toContain("gateway.controlUi.allowedOrigins");
} else {
expect(writes[0]).toContain(
'"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789"]',
);
}
},
);
it("allows ordinary spaces in host persistence paths and quotes generated mounts", async () => {
await resetDockerLog(sandbox);
const configDir = join(sandbox.rootDir, "config with spaces");
const workspaceDir = join(sandbox.rootDir, "workspace with spaces");
const authProfileSecretDir = join(sandbox.rootDir, "auth secrets with spaces");
const homeVolumeDir = join(sandbox.rootDir, "home volume with spaces");
const extraMountSource = join(sandbox.rootDir, "extra data");
const result = runDockerSetup(sandbox, {
OPENCLAW_CONFIG_DIR: configDir,
OPENCLAW_WORKSPACE_DIR: workspaceDir,
OPENCLAW_AUTH_PROFILE_SECRET_DIR: authProfileSecretDir,
OPENCLAW_HOME_VOLUME: homeVolumeDir,
OPENCLAW_EXTRA_MOUNTS: `${extraMountSource}:/mnt/extra data:ro`,
});
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("cannot contain whitespace");
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain(`OPENCLAW_CONFIG_DIR=${configDir}`);
expect(envFile).toContain(`OPENCLAW_WORKSPACE_DIR=${workspaceDir}`);
expect(envFile).toContain(`OPENCLAW_AUTH_PROFILE_SECRET_DIR=${authProfileSecretDir}`);
const extraCompose = await readFile(join(sandbox.rootDir, "docker-compose.extra.yml"), "utf8");
expect(extraCompose).toContain(`"${homeVolumeDir}:/home/node"`);
expect(extraCompose).toContain(`"${configDir}:/home/node/.openclaw"`);
expect(extraCompose).toContain(`"${workspaceDir}:/home/node/.openclaw/workspace"`);
expect(extraCompose).toContain(`"${authProfileSecretDir}:/home/node/.config/openclaw"`);
expect(extraCompose).toContain(`"${extraMountSource}:/mnt/extra data:ro"`);
});
it("persists explicit Docker Bonjour opt-in overrides", async () => {
const result = runDockerSetup(sandbox, {
OPENCLAW_DISABLE_BONJOUR: "0",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_DISABLE_BONJOUR=0");
});
it("persists and forwards signal-specific OTLP protocol overrides", async () => {
const protocolEnv = {
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf",
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: "http/protobuf",
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/protobuf",
};
const result = runDockerSetup(sandbox, protocolEnv);
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
for (const [key, value] of Object.entries(protocolEnv)) {
expect(envFile).toContain(`${key}=${value}`);
expect(compose).toContain(`${key}: \${${key}:-}`);
}
});
it("prefers OPENCLAW_IMAGE_APT_PACKAGES over legacy OPENCLAW_DOCKER_APT_PACKAGES", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
OPENCLAW_IMAGE_APT_PACKAGES: "curl wget httpie",
OPENCLAW_DOCKER_APT_PACKAGES: "curl wget",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_IMAGE_APT_PACKAGES=curl wget httpie");
expect(envFile).not.toContain("OPENCLAW_DOCKER_APT_PACKAGES");
const log = await readDockerLog(sandbox);
expect(log).toContain("--build-arg OPENCLAW_IMAGE_APT_PACKAGES=curl wget httpie");
expect(log).not.toMatch(/--build-arg OPENCLAW_IMAGE_APT_PACKAGES=curl wget(?! httpie)/);
});
it("explicitly empty OPENCLAW_IMAGE_APT_PACKAGES suppresses legacy fallback", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
OPENCLAW_IMAGE_APT_PACKAGES: "",
OPENCLAW_DOCKER_APT_PACKAGES: "curl wget",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_IMAGE_APT_PACKAGES=");
expect(envFile).not.toContain("curl wget");
const log = await readDockerLog(sandbox);
expect(log).not.toContain("--build-arg OPENCLAW_IMAGE_APT_PACKAGES=curl wget");
});
it("avoids shared-network openclaw-cli before the gateway is started", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox);
expect(result.status).toBe(0);
const lines = await readDockerLogLines(sandbox);
const gatewayStartIdx = findGatewayStartLineIndex(lines);
expect(gatewayStartIdx).toBeGreaterThanOrEqual(0);
const prestartLines = lines.slice(0, gatewayStartIdx);
const prestartCliRunLines = collectMatchingLines(prestartLines, (line) =>
/\bcompose\b.*\brun\b.*\bopenclaw-cli\b/.test(line),
);
expect(prestartCliRunLines).toStrictEqual([]);
});
it("pins setup-time CLI state paths inside the container", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
OPENCLAW_HOME: "/mnt/c/Users/Trevor",
OPENCLAW_STATE_DIR: "/mnt/c/Users/Trevor/.openclaw",
OPENCLAW_CONFIG_PATH: "/mnt/c/Users/Trevor/.openclaw/openclaw.json",
OPENCLAW_SKIP_ONBOARDING: "1",
});
expect(result.status).toBe(0);
const lines = await readDockerLogLines(sandbox);
const gatewayStartIdx = findGatewayStartLineIndex(lines);
expect(gatewayStartIdx).toBeGreaterThanOrEqual(0);
const prestartConfigLines = collectMatchingLines(lines.slice(0, gatewayStartIdx), (line) =>
line.includes(" dist/index.js config "),
);
expect(prestartConfigLines.length).toBeGreaterThan(0);
for (const line of prestartConfigLines) {
expect(line).toContain(prestartContainerEnvFlags);
expect(line).not.toContain("/mnt/c");
}
});
it("forces BuildKit for local and sandbox docker builds", async () => {
await mkdir(join(sandbox.rootDir, "scripts", "docker", "sandbox"), { recursive: true });
await writeFile(
join(sandbox.rootDir, "scripts", "docker", "sandbox", "Dockerfile"),
"FROM scratch\n",
);
await resetDockerLog(sandbox);
const socketPath = join(sandbox.rootDir, "buildkit.sock");
await withUnixSocket(socketPath, async () => {
const result = runDockerSetup(sandbox, {
OPENCLAW_SANDBOX: "1",
OPENCLAW_DOCKER_SOCKET: socketPath,
});
expect(result.status).toBe(0);
const buildLines = collectMatchingLines(await readDockerLogLines(sandbox), (line) =>
line.startsWith("build "),
);
expect(buildLines.length).toBeGreaterThanOrEqual(2);
const buildLinesWithoutBuildKit = collectMatchingLines(
buildLines,
(line) => !line.includes("DOCKER_BUILDKIT=1"),
);
expect(buildLinesWithoutBuildKit).toStrictEqual([]);
});
});
it("offline mode reuses a preloaded local image without build or pull", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(
sandbox,
{
OPENCLAW_IMAGE: "ghcr.io/openclaw/openclaw:latest",
OPENCLAW_SKIP_ONBOARDING: "1",
},
["--offline"],
);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
"Using preloaded Docker image: ghcr.io/openclaw/openclaw:latest",
);
const lines = await readDockerLogLines(sandbox);
const log = lines.join("\n");
expect(log).toContain("image inspect ghcr.io/openclaw/openclaw:latest");
expect(log).not.toMatch(/^build /m);
expect(log).not.toMatch(/^pull /m);
expect(log).toContain("config set --batch-json");
expectOfflineComposePolicy(lines);
});
it("offline mode fails before setup when the main image is missing", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(
sandbox,
{
OPENCLAW_IMAGE: "ghcr.io/openclaw/openclaw:offline",
DOCKER_STUB_MISSING_IMAGES: "ghcr.io/openclaw/openclaw:offline",
},
["--offline"],
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
"Offline Docker setup requires preloaded image ghcr.io/openclaw/openclaw:offline",
);
const log = await readDockerLog(sandbox);
expect(log).toContain("image inspect ghcr.io/openclaw/openclaw:offline");
expect(log).not.toMatch(/^build /m);
expect(log).not.toMatch(/^pull /m);
expect(log).not.toContain("up -d openclaw-gateway");
});
it("offline sandbox stays disabled when its configured image is missing", async () => {
await mkdir(join(sandbox.rootDir, "scripts", "docker", "sandbox"), { recursive: true });
await writeFile(
join(sandbox.rootDir, "scripts", "docker", "sandbox", "Dockerfile"),
"FROM scratch\n",
);
await resetDockerLog(sandbox);
const socketPath = join(sandbox.rootDir, "sb.sock");
await withUnixSocket(socketPath, async () => {
const defaultImage = "registry.example/openclaw-sandbox:approved";
const agentImage = " registry.example/openclaw-sandbox:agent ";
const result = runDockerSetup(
sandbox,
{
OPENCLAW_SANDBOX: "1",
OPENCLAW_SKIP_ONBOARDING: "1",
OPENCLAW_DOCKER_SOCKET: socketPath,
DOCKER_STUB_AGENTS_JSON: JSON.stringify({
defaults: { sandbox: { docker: { image: defaultImage } } },
list: [{ id: "custom", sandbox: { docker: { image: agentImage } } }],
}),
DOCKER_STUB_MISSING_IMAGES: agentImage,
},
["--offline"],
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("cannot use required sandbox images");
expect(result.stderr).toContain(agentImage);
expect(result.stderr).toContain(
"Offline sandbox prerequisites are incomplete; sandbox configuration was not changed",
);
const lines = await readDockerLogLines(sandbox);
const log = lines.join("\n");
expect(log).toContain("image inspect openclaw:local");
expect(log).not.toContain(`image inspect ${defaultImage}`);
expect(log).toContain(`image inspect ${agentImage} host=unix://${socketPath}`);
expect(log).not.toContain("image inspect openclaw-sandbox:bookworm-slim");
expect(log).not.toMatch(/^build /m);
expect(log).not.toMatch(/^pull /m);
expect(log).not.toContain("config set agents.defaults.sandbox.mode off");
expect(log).not.toContain("config set agents.defaults.sandbox.mode non-main");
expectOfflineComposePolicy(lines, { gatewayStarts: false });
});
});
it("offline sandbox validates only effective Docker and browser images", async () => {
await resetDockerLog(sandbox);
const socketPath = join(sandbox.rootDir, "eff.sock");
await withUnixSocket(socketPath, async () => {
const defaultImage = "registry.example/openclaw-sandbox:default";
const browserImage = "registry.example/openclaw-sandbox-browser:default";
const ignoredImages = [
"registry.example/openclaw-sandbox:ssh",
"registry.example/openclaw-sandbox:shared-agent",
"registry.example/openclaw-sandbox-browser:shared-agent",
"registry.example/openclaw-sandbox:off",
"registry.example/openclaw-sandbox-browser:denied",
];
const result = runDockerSetup(
sandbox,
{
OPENCLAW_SANDBOX: "1",
OPENCLAW_SKIP_ONBOARDING: "1",
OPENCLAW_DOCKER_SOCKET: socketPath,
DOCKER_STUB_AGENTS_JSON: JSON.stringify({
defaults: {
sandbox: {
backend: "Docker",
docker: { image: defaultImage },
browser: { enabled: true, image: browserImage },
},
},
list: [
{ id: "ssh", sandbox: { backend: "ssh", docker: { image: ignoredImages[0] } } },
{
id: "shared",
sandbox: {
scope: "shared",
docker: { image: ignoredImages[1] },
browser: { image: ignoredImages[2] },
},
},
{ id: "off", sandbox: { mode: "off", docker: { image: ignoredImages[3] } } },
{
id: "browser-denied",
sandbox: { browser: { enabled: true, image: ignoredImages[4] } },
tools: { sandbox: { tools: { deny: ["browser"] } } },
},
],
}),
DOCKER_STUB_SANDBOX_TOOLS_JSON: JSON.stringify({ alsoAllow: ["group:ui"] }),
DOCKER_STUB_BROWSER_CONTRACT: "2026-05-12-cdp-relay-auth",
DOCKER_STUB_MISSING_IMAGES: ignoredImages.join(","),
},
["--offline"],
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain(` - ${defaultImage}`);
expect(result.stdout).toContain(` - ${browserImage}`);
const lines = await readDockerLogLines(sandbox);
const log = lines.join("\n");
expect(log).toContain(`image inspect ${defaultImage} host=unix://${socketPath}`);
expect(log).toContain(`image inspect ${browserImage} host=unix://${socketPath}`);
for (const image of ignoredImages) {
expect(log).not.toContain(`image inspect ${image}`);
}
expect(log).toContain("config set agents.defaults.sandbox.mode non-main");
expectOfflineComposePolicy(lines);
});
});
it("offline sandbox rejects an incompatible browser image", async () => {
await resetDockerLog(sandbox);
const socketPath = join(sandbox.rootDir, "br.sock");
await withUnixSocket(socketPath, async () => {
const browserImage = "registry.example/openclaw-sandbox-browser:stale";
const result = runDockerSetup(
sandbox,
{
OPENCLAW_SANDBOX: "1",
OPENCLAW_SKIP_ONBOARDING: "1",
OPENCLAW_DOCKER_SOCKET: socketPath,
DOCKER_STUB_AGENTS_JSON: JSON.stringify({
defaults: { sandbox: { browser: { enabled: true, image: browserImage } } },
}),
DOCKER_STUB_SANDBOX_TOOLS_JSON: JSON.stringify({ alsoAllow: ["browser"] }),
DOCKER_STUB_BROWSER_CONTRACT: "old-contract",
},
["--offline"],
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
`${browserImage} (browser contract=old-contract, expected=2026-05-12-cdp-relay-auth)`,
);
expect(result.stderr).toContain(
"Offline sandbox prerequisites are incomplete; sandbox configuration was not changed",
);
const lines = await readDockerLogLines(sandbox);
const log = lines.join("\n");
expect(log).toContain(`image inspect ${browserImage} host=unix://${socketPath}`);
expect(log).not.toContain("config set agents.defaults.sandbox.mode off");
expect(log).not.toContain("config set agents.defaults.sandbox.mode non-main");
expectOfflineComposePolicy(lines, { gatewayStarts: false });
});
});
it("precreates config identity dir for CLI device auth writes", async () => {
const configDir = join(sandbox.rootDir, "config-identity");
const workspaceDir = join(sandbox.rootDir, "workspace-identity");
const result = runDockerSetup(sandbox, {
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("writes OPENCLAW_TZ into .env when given a real IANA timezone", async () => {
const result = runDockerSetup(sandbox, {
OPENCLAW_TZ: "Asia/Shanghai",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_TZ=Asia/Shanghai");
});
it("precreates agent data dirs to avoid EACCES in container", async () => {
const configDir = join(sandbox.rootDir, "config-agent-dirs");
const workspaceDir = join(configDir, "workspace");
const stateFiles = [
"identity/owned.txt",
"agents/workspace/owned.txt",
"workspace-archive/owned.txt",
];
const workspaceFile = "workspace/project/user.txt";
for (const relative of [...stateFiles, workspaceFile]) {
const path = join(configDir, relative);
await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, "fixture");
}
expect((await stat(workspaceDir)).dev).toBe((await stat(configDir)).dev);
const result = runDockerSetup(sandbox, {
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 setup.
const log = await readDockerLog(sandbox);
const chownIdx = log.indexOf("--user root");
const safePathIdx = log.indexOf(`${prestartSafePath}; export PATH`);
const stateRepair = log.match(/\/usr\/bin\/find -P \/home\/node\/\.openclaw [^;]+/u)?.[0];
if (!stateRepair) {
throw new Error("Missing generated state ownership repair");
}
const stateRepairIdx = log.indexOf(stateRepair);
const onboardIdx = log.indexOf("onboard");
expect(chownIdx).toBeGreaterThanOrEqual(0);
expect(safePathIdx).toBeGreaterThan(chownIdx);
expect(stateRepairIdx).toBeGreaterThan(safePathIdx);
expect(onboardIdx).toBeGreaterThan(chownIdx);
expect(log).toContain("run --rm --no-deps --user root --entrypoint sh openclaw-gateway -c");
expect(log).toContain("/usr/bin/chown -h node:node /home/node/.config");
expect(stateRepair).toContain("-execdir /usr/bin/chown -h node:node {} +");
expect(log).toContain(noFollowOwnershipRepair("/home/node/.config/openclaw"));
expect(log).toContain("[ ! -L /home/node/.openclaw/workspace/.openclaw ]");
expect(log).toContain(noFollowOwnershipRepair("/home/node/.openclaw/workspace/.openclaw"));
expect(log).toContain("fi || true");
expect(log).not.toContain("-type d -o -type f");
expect(log).not.toContain("-exec chown");
expect(log).not.toContain(" chown node:node");
expect(log).not.toContain("chown -R node:node /home/node/.openclaw/workspace/.openclaw");
// Execute the generated traversal, replacing only the ownership side effect.
// Same-device workspace mounts are not excluded by find's -xdev option.
const selection = stateRepair
.replaceAll("/home/node/.openclaw", '"$repair_root"')
.replace(/-execdir \/usr\/bin\/chown -h node:node \{\} \+$/u, "-print");
expect(selection).not.toContain("chown");
const traversal = spawnSync(
"bash",
["-c", 'repair_root="$(cd "$1" && pwd)"; ' + selection, "ownership-repair", configDir],
{ encoding: "utf8" },
);
expect(traversal.status, traversal.stderr).toBe(0);
const selected = traversal.stdout.trim().split(/\r?\n/u);
const selectedRoot = selected[0];
expect(selectedRoot).toMatch(/\/config-agent-dirs$/u);
for (const relative of stateFiles) {
expect(selected).toContain(`${selectedRoot}/${relative}`);
}
expect(selected).toContain(`${selectedRoot}/workspace`);
expect(selected).not.toContain(`${selectedRoot}/${workspaceFile}`);
});
it("precreates auth profile secret key dir outside the mounted state dir", async () => {
const configDir = join(sandbox.rootDir, "config-auth-profile-key");
const workspaceDir = join(sandbox.rootDir, "workspace-auth-profile-key");
const secretDir = join(sandbox.rootDir, "auth-profile-secret-key");
const result = runDockerSetup(sandbox, {
OPENCLAW_CONFIG_DIR: configDir,
OPENCLAW_WORKSPACE_DIR: workspaceDir,
OPENCLAW_AUTH_PROFILE_SECRET_DIR: secretDir,
});
expect(result.status).toBe(0);
const secretDirStat = await stat(secretDir);
expect(secretDirStat.isDirectory()).toBe(true);
expect(secretDir.startsWith(`${configDir}/`)).toBe(false);
const log = await readDockerLog(sandbox);
expect(log).toContain(noFollowOwnershipRepair("/home/node/.config/openclaw"));
});
it("reuses existing config token when OPENCLAW_GATEWAY_TOKEN is unset", async () => {
const { result, envFile } = await runDockerSetupWithUnsetGatewayToken(
sandbox,
"token-reuse",
async (configDir) => {
await writeFile(
join(configDir, "openclaw.json"),
JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }),
);
},
);
expect(result.status).toBe(0);
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); // pragma: allowlist secret
});
it("reuses the last non-empty .env token and strips CRLF without truncating '='", async () => {
await writeFile(
join(sandbox.rootDir, ".env"),
[
"OPENCLAW_GATEWAY_TOKEN=",
"OPENCLAW_GATEWAY_TOKEN=first-token",
"OPENCLAW_GATEWAY_TOKEN=last=token=value\r", // pragma: allowlist secret
].join("\n"),
);
const { result, envFile } = await runDockerSetupWithUnsetGatewayToken(
sandbox,
"dotenv-last-wins",
);
expect(result.status).toBe(0);
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=last=token=value"); // pragma: allowlist secret
expect(envFile).not.toContain("OPENCLAW_GATEWAY_TOKEN=first-token");
expect(envFile).not.toContain("\r");
expect(result.stderr).toBe("");
});
it("treats OPENCLAW_SANDBOX=0 as disabled", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
OPENCLAW_SANDBOX: "0",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_SANDBOX=");
const log = await readDockerLog(sandbox);
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 () => {
await resetDockerLog(sandbox);
await writeFile(
join(sandbox.rootDir, "docker-compose.sandbox.yml"),
"services:\n openclaw-gateway:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n",
);
const socketPath = join(sandbox.rootDir, "missing-cli.sock");
await withUnixSocket(socketPath, async () => {
const result = runDockerSetup(sandbox, {
OPENCLAW_SANDBOX: "1",
OPENCLAW_DOCKER_SOCKET: socketPath,
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 readDockerLog(sandbox);
expect(log).toContain("config set agents.defaults.sandbox.mode off");
await expectMissingPath(join(sandbox.rootDir, "docker-compose.sandbox.yml"));
});
});
it("keeps offline policy when sandbox config writes fail and the gateway rolls back", async () => {
await resetDockerLog(sandbox);
const socketPath = join(sandbox.rootDir, "sandbox.sock");
await withUnixSocket(socketPath, async () => {
const result = runDockerSetup(
sandbox,
{
OPENCLAW_SANDBOX: "1",
OPENCLAW_DOCKER_SOCKET: socketPath,
DOCKER_STUB_FAIL_MATCH: "config set agents.defaults.sandbox.scope",
},
["--offline"],
);
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 lines = await readDockerLogLines(sandbox);
const log = lines.join("\n");
const gatewayStarts = collectMatchingLines(lines, (line) => isGatewayStartLine(line));
expect(gatewayStarts).toHaveLength(2);
expect(log).toContain(
"run --pull never --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("--force-recreate openclaw-gateway"));
expect(forceRecreateLine).toBe(
`compose compose -f ${join(sandbox.rootDir, "docker-compose.yml")} up -d --pull never --no-build --force-recreate openclaw-gateway`,
);
expect(forceRecreateLine).not.toContain("docker-compose.sandbox.yml");
expect(log).toContain(
`image inspect openclaw-sandbox:bookworm-slim host=unix://${socketPath}`,
);
expectOfflineComposePolicy(lines);
await expectMissingPath(join(sandbox.rootDir, "docker-compose.sandbox.yml"));
});
});
it.each([
[
"OPENCLAW_EXTRA_MOUNTS",
"/tmp:/tmp\n evil-service:\n image: alpine",
"OPENCLAW_EXTRA_MOUNTS cannot contain control characters",
],
["OPENCLAW_EXTRA_MOUNTS", "bad mount spec", "Invalid mount format"],
["OPENCLAW_HOME_VOLUME", "bad name", "OPENCLAW_HOME_VOLUME must match"],
["OPENCLAW_TZ", "Nope/Bad", "OPENCLAW_TZ must be supported by openclaw:local"],
])("rejects invalid %s=%j", (key, value, diagnostic) => {
const result = runDockerSetup(sandbox, { [key]: value });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(diagnostic);
});
it("skips onboarding when OPENCLAW_SKIP_ONBOARDING is set", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
OPENCLAW_SKIP_ONBOARDING: "1",
});
expect(result.status).toBe(0);
const log = await readDockerLog(sandbox);
expect(log).not.toContain("onboard");
// Gateway defaults (config set) and control UI allowlist should still run.
expect(log).toContain("config set --batch-json");
expect(log).toContain('"path":"gateway.mode","value":"local"');
expect(log).toContain('"path":"gateway.bind","value":"lan"');
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_SKIP_ONBOARDING=1");
});
it("treats OPENCLAW_SKIP_ONBOARDING=0 as disabled and runs onboarding", async () => {
await resetDockerLog(sandbox);
const result = runDockerSetup(sandbox, {
OPENCLAW_SKIP_ONBOARDING: "0",
});
expect(result.status).toBe(0);
const log = await readDockerLog(sandbox);
expect(log).toContain(
"onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output",
);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toMatch(/OPENCLAW_SKIP_ONBOARDING=\n/);
});
it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => {
const script = await readFile(join(repoRoot, "scripts", "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, "scripts", "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 gateway Bonjour advertising in auto mode by default", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(
compose.match(/OPENCLAW_DISABLE_BONJOUR: \$\{OPENCLAW_DISABLE_BONJOUR:-\}/g),
).toHaveLength(1);
});
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");
});
it("keeps docker-compose gateway token env defaults aligned across services", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(compose.match(/OPENCLAW_GATEWAY_TOKEN: \$\{OPENCLAW_GATEWAY_TOKEN:-\}/g)).toHaveLength(
2,
);
});
it("keeps docker-compose auth profile secret key source durable outside state", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(
compose.split(
'"${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw"',
),
).toHaveLength(3);
});
it("keeps docker-compose optional env files aligned across services", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(compose.match(/env_file:\n {6}- path: \.env\n {8}required: false/g)).toHaveLength(2);
});
it("keeps docker-compose timezone env defaults aligned across services", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2);
});
it("isolates container paths and listener port from host .env values on both services", async () => {
const { services } = parse(await readFile(join(repoRoot, "docker-compose.yml"), "utf8")) as {
services: Record<
"openclaw-gateway" | "openclaw-cli",
{
environment: Record<string, string>;
command: string[];
ports: string[];
}
>;
};
const gateway = services["openclaw-gateway"];
const listenerPort = gateway.command[gateway.command.indexOf("--port") + 1];
expect(listenerPort).toBe("18789");
expect(gateway.ports).toContain(`\${OPENCLAW_GATEWAY_PORT:-18789}:${listenerPort}`);
for (const name of ["openclaw-gateway", "openclaw-cli"] as const) {
expect(services[name].environment, name).toMatchObject({
OPENCLAW_HOME: "/home/node",
OPENCLAW_STATE_DIR: "/home/node/.openclaw",
OPENCLAW_CONFIG_PATH: "/home/node/.openclaw/openclaw.json",
OPENCLAW_CONFIG_DIR: "/home/node/.openclaw",
OPENCLAW_WORKSPACE_DIR: "/home/node/.openclaw/workspace",
OPENCLAW_GATEWAY_PORT: listenerPort,
});
}
});
it("Dockerfile ARG OPENCLAW_IMAGE_APT_PACKAGES must not have a default value", async () => {
// A default makes the ARG set, suppressing the legacy build-arg fallback.
const dockerfile = await readFile(join(repoRoot, "Dockerfile"), "utf8");
const argLine = dockerfile
.split("\n")
.find((line) => line.startsWith("ARG OPENCLAW_IMAGE_APT_PACKAGES"));
expect(argLine).toBe("ARG OPENCLAW_IMAGE_APT_PACKAGES");
});
});