From 6731c6a1cd7d37b9ff50ef7146e167063081d41f Mon Sep 17 00:00:00 2001 From: Mateusz Michalik Date: Wed, 11 Feb 2026 01:55:43 +1100 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + docker-setup.sh | 20 +++-- src/docker-setup.test.ts | 182 +++++++++++++++++++++++---------------- 3 files changed, 122 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 165b043e0f..b951975e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale. - Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai. - Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual). +- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik. - Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. - Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow. diff --git a/docker-setup.sh b/docker-setup.sh index 89b8346a32..1d2f5e53fd 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -56,7 +56,6 @@ COMPOSE_ARGS=() write_extra_compose() { local home_volume="$1" shift - local -a mounts=("$@") local mount cat >"$EXTRA_COMPOSE_FILE" <<'YAML' @@ -71,7 +70,7 @@ YAML printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE" fi - for mount in "${mounts[@]}"; do + for mount in "$@"; do printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" done @@ -86,7 +85,7 @@ YAML printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE" fi - for mount in "${mounts[@]}"; do + for mount in "$@"; do printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" done @@ -111,7 +110,12 @@ if [[ -n "$EXTRA_MOUNTS" ]]; then fi if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then - write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}" + # Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound. + if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then + write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}" + else + write_extra_compose "$HOME_VOLUME_NAME" + fi COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE") fi for compose_file in "${COMPOSE_FILES[@]}"; do @@ -129,7 +133,9 @@ upsert_env() { local -a keys=("$@") local tmp tmp="$(mktemp)" - declare -A seen=() + # Use a delimited string instead of an associative array so the script + # works with Bash 3.2 (macOS default) which lacks `declare -A`. + local seen=" " if [[ -f "$file" ]]; then while IFS= read -r line || [[ -n "$line" ]]; do @@ -138,7 +144,7 @@ upsert_env() { for k in "${keys[@]}"; do if [[ "$key" == "$k" ]]; then printf '%s=%s\n' "$k" "${!k-}" >>"$tmp" - seen["$k"]=1 + seen="$seen$k " replaced=true break fi @@ -150,7 +156,7 @@ upsert_env() { fi for k in "${keys[@]}"; do - if [[ -z "${seen[$k]:-}" ]]; then + if [[ "$seen" != *" $k "* ]]; then printf '%s=%s\n' "$k" "${!k-}" >>"$tmp" fi done diff --git a/src/docker-setup.test.ts b/src/docker-setup.test.ts index 1b6abcc5fb..334221a580 100644 --- a/src/docker-setup.test.ts +++ b/src/docker-setup.test.ts @@ -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 { + 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 = {}, +): 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"); });