fix(docker): support Bash 3.2 in docker-setup.sh (#9441)

* fix(docker): use Bash 3.2-compatible upsert_env in docker-setup.sh

* refactor(docker): simplify argument handling in write_extra_compose function

* fix(docker): add bash 3.2 regression coverage (#9441) (thanks @mateusz-michalik)

---------

Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
This commit is contained in:
Mateusz Michalik
2026-02-11 01:55:43 +11:00
committed by GitHub
parent 2914cb1d48
commit 6731c6a1cd
3 changed files with 122 additions and 81 deletions

View File

@@ -7,6 +7,13 @@ import { 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
@@ -31,105 +38,132 @@ exit 0
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");
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
await writeFile(scriptPath, script, { mode: 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 {
return {
...process.env,
PATH: `${sandbox.binDir}:${process.env.PATH ?? ""}`,
DOCKER_STUB_LOG: sandbox.logPath,
OPENCLAW_GATEWAY_TOKEN: "test-token",
OPENCLAW_CONFIG_DIR: join(sandbox.rootDir, "config"),
OPENCLAW_WORKSPACE_DIR: join(sandbox.rootDir, "openclaw"),
...overrides,
};
}
describe("docker-setup.sh", () => {
it("handles unset optional env vars under strict mode", async () => {
const assocCheck = spawnSync("bash", ["-c", "declare -A _t=()"], {
encoding: "utf8",
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_DOCKER_APT_PACKAGES: undefined,
OPENCLAW_EXTRA_MOUNTS: undefined,
OPENCLAW_HOME_VOLUME: undefined,
});
if (assocCheck.status !== 0) {
return;
}
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");
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
await writeFile(scriptPath, script, { mode: 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);
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
DOCKER_STUB_LOG: logPath,
OPENCLAW_GATEWAY_TOKEN: "test-token",
OPENCLAW_CONFIG_DIR: join(rootDir, "config"),
OPENCLAW_WORKSPACE_DIR: join(rootDir, "openclaw"),
};
delete env.OPENCLAW_DOCKER_APT_PACKAGES;
delete env.OPENCLAW_EXTRA_MOUNTS;
delete env.OPENCLAW_HOME_VOLUME;
const result = spawnSync("bash", [scriptPath], {
cwd: rootDir,
const result = spawnSync("bash", [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(rootDir, ".env"), "utf8");
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=");
expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS=");
expect(envFile).toContain("OPENCLAW_HOME_VOLUME=");
});
it("plumbs OPENCLAW_DOCKER_APT_PACKAGES into .env and docker build args", async () => {
const assocCheck = spawnSync("bash", ["-c", "declare -A _t=()"], {
encoding: "utf8",
});
if (assocCheck.status !== 0) {
return;
}
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");
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
await writeFile(scriptPath, script, { mode: 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);
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
DOCKER_STUB_LOG: logPath,
OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential",
OPENCLAW_GATEWAY_TOKEN: "test-token",
OPENCLAW_CONFIG_DIR: join(rootDir, "config"),
OPENCLAW_WORKSPACE_DIR: join(rootDir, "openclaw"),
it("supports a home volume when extra mounts are empty", async () => {
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_EXTRA_MOUNTS: "",
OPENCLAW_HOME_VOLUME: "",
};
OPENCLAW_HOME_VOLUME: "openclaw-home",
});
const result = spawnSync("bash", [scriptPath], {
cwd: rootDir,
const result = spawnSync("bash", [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(rootDir, ".env"), "utf8");
const extraCompose = await readFile(join(sandbox.rootDir, "docker-compose.extra.yml"), "utf8");
expect(extraCompose).toContain("openclaw-home:/home/node");
expect(extraCompose).toContain("volumes:");
expect(extraCompose).toContain("openclaw-home:");
});
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 = "/bin/bash";
const assocCheck = spawnSync(systemBash, ["-c", "declare -A _t=()"], {
encoding: "utf8",
});
if (assocCheck.status === 0) {
return;
}
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_EXTRA_MOUNTS: "",
OPENCLAW_HOME_VOLUME: "",
});
const result = spawnSync(systemBash, [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("declare: -A: invalid option");
});
it("plumbs OPENCLAW_DOCKER_APT_PACKAGES into .env and docker build args", async () => {
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential",
OPENCLAW_EXTRA_MOUNTS: "",
OPENCLAW_HOME_VOLUME: "",
});
const result = spawnSync("bash", [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential");
const log = await readFile(logPath, "utf8");
const log = await readFile(sandbox.logPath, "utf8");
expect(log).toContain("--build-arg OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential");
});