diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 3f35fc0adce8..809d1967bf3b 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -409,9 +409,16 @@ contains_disallowed_chars() { [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] } -is_valid_timezone() { +is_valid_timezone_in_image() { local value="$1" - [[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]] + docker run --rm --network none --entrypoint node "$IMAGE_NAME" -e ' +const timezone = process.argv[1]; +try { + new Intl.DateTimeFormat("en", { timeZone: timezone }).format(0); +} catch { + process.exit(1); +} +' "$value" } validate_mount_path_value() { @@ -497,9 +504,6 @@ if [[ -n "$TIMEZONE" ]]; then if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)." fi - if ! is_valid_timezone "$TIMEZONE"; then - fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)." - fi fi mkdir -p "$OPENCLAW_CONFIG_DIR" @@ -782,6 +786,10 @@ else fi fi +if [[ -n "$TIMEZONE" ]] && ! is_valid_timezone_in_image "$TIMEZONE"; then + fail "OPENCLAW_TZ must be supported by $IMAGE_NAME (e.g. Asia/Shanghai)." +fi + # Ensure bind-mounted data directories are writable by the container's `node` # user (uid 1000). Host-created dirs inherit the host user's uid which may # differ, causing EACCES when the container tries to mkdir/write. diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index bb470a1796e3..de0148da6681 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -71,6 +71,54 @@ function extractInstallE2eInstallerFunction(): string { return script.slice(start, end); } +function extractDockerTimezoneValidator(): string { + const script = readFileSync(DOCKER_SETUP_PATH, "utf8"); + const match = script.match( + /(is_valid_timezone_in_image\(\) \{[\s\S]*?\n\})\n\nvalidate_mount_path_value/u, + ); + if (!match) { + throw new Error("Docker timezone validator was not found"); + } + return expectDefined(match[1], "Docker timezone validator capture"); +} + +function runDockerTimezoneValidator(timezone: string) { + const root = tempDirs.make("openclaw-docker-timezone-"); + const binDir = join(root, "bin"); + const dockerPath = join(binDir, "docker"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + dockerPath, + [ + "#!/bin/bash", + "set -euo pipefail", + 'while [[ "$#" -gt 0 && "$1" != "-e" ]]; do shift; done', + 'exec "$HOST_NODE" "$@"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + + return spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `${extractDockerTimezoneValidator()}\nIMAGE_NAME=openclaw:test\nis_valid_timezone_in_image "$TIMEZONE"`, + ], + { + encoding: "utf8", + env: { + HOME: root, + HOST_NODE: process.execPath, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + TIMEZONE: timezone, + }, + }, + ); +} + function runInstallE2eInstallerFixture(params: { curlExitCode?: number; installTag: string; @@ -904,6 +952,25 @@ printf 'status=%s\\n' "$status" expect(script).not.toContain('docker pull "$IMAGE_NAME"'); }); + it("validates Docker timezones against the selected image runtime", () => { + for (const timezone of ["Asia/Shanghai", "UTC", "US/Pacific"]) { + const result = runDockerTimezoneValidator(timezone); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + } + + for (const timezone of ["zone.tab", "iso3166.tab", "Factory", "localtime"]) { + const result = runDockerTimezoneValidator(timezone); + expect(result.status).toBe(1); + } + + const script = readFileSync(DOCKER_SETUP_PATH, "utf8"); + expect(script).not.toContain("/usr/share/zoneinfo"); + expect(script).toContain( + 'fail "OPENCLAW_TZ must be supported by $IMAGE_NAME (e.g. Asia/Shanghai)."', + ); + }); + it("bounds Podman setup image pulls", () => { const script = readFileSync(PODMAN_SETUP_PATH, "utf8");