diff --git a/docker-compose.yml b/docker-compose.yml index 17800eb8a01..88845423151 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,9 +39,9 @@ services: CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} TZ: ${OPENCLAW_TZ:-UTC} volumes: - - ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw - - ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace - - ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw + - "${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw" + - "${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace" + - "${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw" ## Uncomment the lines below to enable sandbox isolation ## (agents.defaults.sandbox). Requires Docker CLI in the image ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use @@ -118,9 +118,9 @@ services: CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} TZ: ${OPENCLAW_TZ:-UTC} volumes: - - ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw - - ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace - - ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw + - "${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw" + - "${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace" + - "${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw" stdin_open: true tty: true init: true diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 678053268dd..2134e5d30de 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -218,9 +218,6 @@ validate_mount_path_value() { if contains_disallowed_chars "$value"; then fail "$label contains unsupported control characters." fi - if [[ "$value" =~ [[:space:]] ]]; then - fail "$label cannot contain whitespace." - fi } validate_named_volume() { @@ -237,11 +234,18 @@ validate_mount_spec() { fi # Keep mount specs strict to avoid YAML structure injection. # Expected format: source:target[:options] - if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then - fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces." + if [[ ! "$mount" =~ ^[^,:]+:[^,:]+(:[^,:]+)?$ ]]; then + fail "Invalid mount format '$mount'. Expected source:target[:options] without commas or control characters." fi } +quote_yaml_string() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '"%s"' "$value" +} + require_cmd docker if ! docker compose version >/dev/null 2>&1; then echo "Docker Compose not available (try: docker compose version)" >&2 @@ -388,15 +392,15 @@ YAML validate_mount_spec "$gateway_config_mount" validate_mount_spec "$gateway_workspace_mount" validate_mount_spec "$gateway_auth_profile_secret_mount" - printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_auth_profile_secret_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_home_mount")" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_config_mount")" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_workspace_mount")" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_auth_profile_secret_mount")" >>"$EXTRA_COMPOSE_FILE" fi for mount in "$@"; do validate_mount_spec "$mount" - printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$mount")" >>"$EXTRA_COMPOSE_FILE" done cat >>"$EXTRA_COMPOSE_FILE" <<'YAML' @@ -405,15 +409,15 @@ YAML YAML if [[ -n "$home_volume" ]]; then - printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_auth_profile_secret_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_home_mount")" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_config_mount")" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_workspace_mount")" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$gateway_auth_profile_secret_mount")" >>"$EXTRA_COMPOSE_FILE" fi for mount in "$@"; do validate_mount_spec "$mount" - printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$(quote_yaml_string "$mount")" >>"$EXTRA_COMPOSE_FILE" done if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then @@ -658,7 +662,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then services: openclaw-gateway: volumes: - - ${DOCKER_SOCKET_PATH}:/var/run/docker.sock + - $(quote_yaml_string "${DOCKER_SOCKET_PATH}:/var/run/docker.sock") YAML if [[ -n "${DOCKER_GID:-}" ]]; then cat >>"$SANDBOX_COMPOSE_FILE" < { expect(log).not.toContain("run --rm openclaw-cli onboard --mode local --no-install-daemon"); }); + it("allows ordinary spaces in host persistence paths and quotes generated mounts", async () => { + const activeSandbox = requireSandbox(sandbox); + await resetDockerLog(activeSandbox); + const configDir = join(activeSandbox.rootDir, "config with spaces"); + const workspaceDir = join(activeSandbox.rootDir, "workspace with spaces"); + const authProfileSecretDir = join(activeSandbox.rootDir, "auth secrets with spaces"); + const homeVolumeDir = join(activeSandbox.rootDir, "home volume with spaces"); + const extraMountSource = join(activeSandbox.rootDir, "extra data"); + + const result = runDockerSetup(activeSandbox, { + 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(activeSandbox.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(activeSandbox.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 activeSandbox = requireSandbox(sandbox); @@ -781,7 +818,7 @@ describe("scripts/docker/setup.sh", () => { 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", + '"${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw"', ), ).toHaveLength(3); });