From b83dfcb953c901b2bc115955f29dfe879cb1bde4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 25 May 2026 18:58:38 +0200 Subject: [PATCH] fix(installer): handle alpine apk runtime floors --- CHANGELOG.md | 1 + docs/install/installer.md | 7 +- scripts/install.sh | 73 ++++++++++-- test/scripts/install-sh.test.ts | 195 +++++++++++++++++++++++++++++++- 4 files changed, 261 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7658848db8..3f219a94ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Installer: make Alpine apk installs cover Git, verify the Node runtime floor, try `nodejs-current`, and report Alpine version guidance when repositories only provide older Node packages. - Agents/media: send direct fallback for generated media still missing after an active requester wake fails. (#85489) Thanks @fuller-stack-dev. - Agents: derive overflow compaction budgets from provider-reported and synthetic over-budget token counts so confirmed context overflows compact before retrying. (#70473) Thanks @fuller-stack-dev. - Agents/Codex: recover Codex context-window prompt errors through overflow compaction and surface reset guidance when recovery is exhausted. (#85542) Thanks @fuller-stack-dev. diff --git a/docs/install/installer.md b/docs/install/installer.md index 707e6c58c7a..e8714d079b3 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -72,9 +72,10 @@ Recommended for most interactive installs on macOS/Linux/WSL. Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.19+`, for compatibility. + On Alpine/musl Linux, the installer uses apk packages instead of NodeSource; the configured Alpine repositories must provide Node `22.19+` (Alpine 3.21 or newer at the time of writing). - Installs Git if missing. + Installs Git if missing using the detected package manager, including apk on Alpine. - `npm` method (default): global npm install @@ -187,10 +188,10 @@ by default, plus git-checkout installs under the same prefix flow. Downloads a pinned supported Node LTS tarball (the version is embedded in the script and updated independently) to `/tools/node-v` and verifies SHA-256. - On Alpine/musl Linux, where Node does not publish compatible tarballs for the pinned runtime, installs `nodejs` and `npm` with `apk` and links that runtime into the prefix wrapper path. + On Alpine/musl Linux, where Node does not publish compatible tarballs for the pinned runtime, installs `nodejs` and `npm` with `apk` and links that runtime into the prefix wrapper path. The Alpine repositories must provide Node `22.19+`; use Alpine 3.21 or newer if older repositories only provide Node 20 or 21. - If Git is missing, attempts install via apt/dnf/yum on Linux or Homebrew on macOS. + If Git is missing, attempts install via apt/dnf/yum/apk on Linux or Homebrew on macOS. - `npm` method (default): installs under the prefix with npm, then writes wrapper to `/bin/openclaw` diff --git a/scripts/install.sh b/scripts/install.sh index 1f779abb9b6..44d430d4206 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -624,6 +624,21 @@ is_arch_linux() { return 1 } +is_alpine_linux() { + if [[ -f /etc/alpine-release ]]; then + return 0 + fi + if [[ -f /etc/os-release ]]; then + local os_id os_id_like + os_id="$(grep -E '^ID=' /etc/os-release 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)" + os_id_like="$(grep -E '^ID_LIKE=' /etc/os-release 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)" + if [[ "$os_id" == "alpine" || "$os_id_like" == *alpine* ]]; then + return 0 + fi + fi + return 1 +} + apt_get() { if is_root; then env DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}" NEEDRESTART_MODE="${NEEDRESTART_MODE:-a}" apt-get "$@" @@ -679,7 +694,7 @@ install_build_tools_linux() { return 0 fi - if command -v apk &> /dev/null; then + if command -v apk &> /dev/null && is_alpine_linux; then if is_root; then run_quiet_step "Installing build tools" apk add --no-cache build-base python3 cmake else @@ -1715,6 +1730,44 @@ finish_linux_node_install() { print_active_node_paths || true } +install_node_with_apk() { + ui_info "Installing Node.js via apk (Alpine Linux detected)" + if is_root; then + run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm + else + run_quiet_step "Installing Node.js" sudo apk add --no-cache nodejs npm + fi + + activate_supported_node_on_path || true + if node_is_at_least_required; then + finish_linux_node_install + return 0 + fi + + local apk_node_version + apk_node_version="$(node -v 2>/dev/null || echo "missing")" + ui_warn "Alpine nodejs package installed ${apk_node_version}, below required v${NODE_MIN_VERSION}+" + ui_info "Trying Alpine nodejs-current package" + if is_root; then + run_quiet_step "Installing nodejs-current" apk add --no-cache nodejs-current npm + else + run_quiet_step "Installing nodejs-current" sudo apk add --no-cache nodejs-current npm + fi + + activate_supported_node_on_path || true + if node_is_at_least_required; then + finish_linux_node_install + return 0 + fi + + local active_path active_version + active_path="$(command -v node 2>/dev/null || echo "not found")" + active_version="$(node -v 2>/dev/null || echo "missing")" + ui_error "Alpine apk repositories did not provide Node.js v${NODE_MIN_VERSION}+; found ${active_version} (${active_path})" + echo "Use Alpine 3.21+ or install Node.js ${NODE_DEFAULT_MAJOR} manually, then rerun the installer." + exit 1 +} + # Install Node.js install_node() { if [[ "$OS" == "macos" ]]; then @@ -1751,14 +1804,8 @@ install_node() { return 0 fi - if command -v apk &> /dev/null; then - ui_info "Installing Node.js via apk (Alpine Linux detected)" - if is_root; then - run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm - else - run_quiet_step "Installing Node.js" sudo apk add --no-cache nodejs npm - fi - finish_linux_node_install + if command -v apk &> /dev/null && is_alpine_linux; then + install_node_with_apk return 0 fi @@ -1857,7 +1904,13 @@ install_git() { run_quiet_step "Installing Git" brew install git elif [[ "$OS" == "linux" ]]; then require_sudo - if command -v apt-get &> /dev/null; then + if command -v apk &> /dev/null && is_alpine_linux; then + if is_root; then + run_quiet_step "Installing Git" apk add --no-cache git + else + run_quiet_step "Installing Git" sudo apk add --no-cache git + fi + elif command -v apt-get &> /dev/null; then run_quiet_step "Updating package index" apt_get_update run_quiet_step "Installing Git" apt_get_install git elif command -v pacman &> /dev/null || is_arch_linux; then diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 96efc7c6550..a9f984dd74c 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -109,14 +109,17 @@ describe("install.sh", () => { it("installs Node.js with apk on Alpine before falling back to NodeSource", () => { expect(script).toContain("finish_linux_node_install()"); + expect(script).toContain("is_alpine_linux()"); + expect(script).toContain("install_node_with_apk()"); expect(script).toContain('ui_info "Installing Node.js via apk (Alpine Linux detected)"'); expect(script).toContain('run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm'); expect(script).toContain( 'run_quiet_step "Installing Node.js" sudo apk add --no-cache nodejs npm', ); + expect(script).toContain('run_quiet_step "Installing nodejs-current" apk add --no-cache nodejs-current npm'); expect(script).toContain("if ! node_is_at_least_required; then"); - const apkIndex = script.indexOf("if command -v apk &> /dev/null; then"); + const apkIndex = script.indexOf("if command -v apk &> /dev/null && is_alpine_linux; then"); const nodeSourceIndex = script.indexOf('ui_info "Installing Node.js via NodeSource"'); expect(apkIndex).toBeGreaterThan(-1); expect(nodeSourceIndex).toBeGreaterThan(apkIndex); @@ -130,10 +133,12 @@ describe("install.sh", () => { require_sudo() { :; } install_build_tools_linux() { return 0; } is_root() { return 0; } + is_alpine_linux() { return 0; } ui_info() { printf 'info:%s\\n' "$*"; } ui_success() { printf 'success:%s\\n' "$*"; } run_quiet_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; } apk() { :; } + node_is_at_least_required() { return 0; } finish_linux_node_install() { printf 'finish-linux-node\\n'; } install_node `); @@ -145,6 +150,182 @@ describe("install.sh", () => { expect(result.stdout).not.toContain("Installing Node.js via NodeSource"); }); + it("tries nodejs-current when Alpine nodejs is below the runtime floor", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + OS=linux + NODE_FAKE_VERSION=v20.15.1 + require_sudo() { :; } + install_build_tools_linux() { return 0; } + is_root() { return 0; } + is_alpine_linux() { return 0; } + ui_info() { printf 'info:%s\\n' "$*"; } + ui_success() { printf 'success:%s\\n' "$*"; } + ui_warn() { printf 'warn:%s\\n' "$*"; } + run_quiet_step() { + printf 'step:%s|%s\\n' "$1" "\${*:2}" + "\${@:2}" + } + apk() { + printf 'apk:%s\\n' "$*" + if [[ "$*" == *"nodejs-current"* ]]; then + NODE_FAKE_VERSION=v22.22.2 + fi + } + node() { + if [[ "\${1:-}" == "-v" ]]; then + printf '%s\\n' "$NODE_FAKE_VERSION" + fi + } + activate_supported_node_on_path() { :; } + finish_linux_node_install() { printf 'finish-linux-node\\n'; } + install_node + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("step:Installing Node.js|apk add --no-cache nodejs npm"); + expect(result.stdout).toContain("warn:Alpine nodejs package installed v20.15.1"); + expect(result.stdout).toContain("step:Installing nodejs-current|apk add --no-cache nodejs-current npm"); + expect(result.stdout).toContain("finish-linux-node"); + }); + + it("fails with Alpine version guidance when apk cannot provide the runtime floor", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + OS=linux + NODE_FAKE_VERSION=v20.15.1 + require_sudo() { :; } + install_build_tools_linux() { return 0; } + is_root() { return 0; } + is_alpine_linux() { return 0; } + ui_info() { printf 'info:%s\\n' "$*"; } + ui_success() { printf 'success:%s\\n' "$*"; } + ui_warn() { printf 'warn:%s\\n' "$*"; } + ui_error() { printf 'error:%s\\n' "$*"; } + run_quiet_step() { + printf 'step:%s|%s\\n' "$1" "\${*:2}" + "\${@:2}" + } + apk() { + printf 'apk:%s\\n' "$*" + if [[ "$*" == *"nodejs-current"* ]]; then + NODE_FAKE_VERSION=v21.7.3 + fi + } + node() { + if [[ "\${1:-}" == "-v" ]]; then + printf '%s\\n' "$NODE_FAKE_VERSION" + fi + } + activate_supported_node_on_path() { :; } + install_node + `); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("warn:Alpine nodejs package installed v20.15.1"); + expect(result.stdout).toContain("step:Installing nodejs-current|apk add --no-cache nodejs-current npm"); + expect(result.stdout).toContain("error:Alpine apk repositories did not provide Node.js v22.19+"); + expect(result.stdout).toContain("Use Alpine 3.21+ or install Node.js 24 manually"); + }); + + it("installs Git with apk on Alpine", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-git-apk-")); + const bin = join(tmp, "bin"); + const apkLog = join(tmp, "apk-args.txt"); + mkdirSync(bin, { recursive: true }); + const fakeApk = join(bin, "apk"); + writeFileSync( + fakeApk, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" >> ${JSON.stringify(apkLog)}`, + "", + ].join("\n"), + ); + chmodSync(fakeApk, 0o755); + + try { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + PATH=${JSON.stringify(`${bin}:/bin`)} + OS=linux + require_sudo() { :; } + is_root() { return 0; } + is_alpine_linux() { return 0; } + ui_success() { printf 'success:%s\\n' "$*"; } + ui_error() { printf 'error:%s\\n' "$*"; } + run_quiet_step() { + printf 'step:%s|%s\\n' "$1" "\${*:2}" + "\${@:2}" + } + install_git + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("step:Installing Git|apk add --no-cache git"); + expect(result.stdout).toContain("success:Git installed"); + expect(readFileSync(apkLog, "utf8").trim()).toBe("add --no-cache git"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("does not select apk Git on non-Alpine hosts", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-git-native-")); + const bin = join(tmp, "bin"); + const apkLog = join(tmp, "apk-args.txt"); + mkdirSync(bin, { recursive: true }); + const fakeApk = join(bin, "apk"); + const fakeApt = join(bin, "apt-get"); + writeFileSync(apkLog, ""); + writeFileSync( + fakeApk, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" >> ${JSON.stringify(apkLog)}`, + "", + ].join("\n"), + ); + writeFileSync(fakeApt, "#!/usr/bin/env bash\nexit 0\n"); + chmodSync(fakeApk, 0o755); + chmodSync(fakeApt, 0o755); + + try { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + PATH=${JSON.stringify(`${bin}:/bin`)} + OS=linux + require_sudo() { :; } + is_root() { return 0; } + is_alpine_linux() { return 1; } + apt_get_update() { printf 'apt-update\\n'; } + apt_get_install() { printf 'apt-install:%s\\n' "$*"; } + ui_success() { printf 'success:%s\\n' "$*"; } + ui_error() { printf 'error:%s\\n' "$*"; } + run_quiet_step() { + printf 'step:%s|%s\\n' "$1" "\${*:2}" + "\${@:2}" + } + install_git + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("step:Updating package index|apt_get_update"); + expect(result.stdout).toContain("apt-update"); + expect(result.stdout).toContain("step:Installing Git|apt_get_install git"); + expect(result.stdout).toContain("apt-install:git"); + expect(readFileSync(apkLog, "utf8")).toBe(""); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + it("clears npm freshness filters for package installs", () => { expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before"); expect(script).toContain('freshness_flag="--min-release-age=0"'); @@ -156,10 +337,12 @@ describe("install.sh", () => { it("does not emit --before when raw user npmrc config contains min-release-age", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npmrc-")); const bin = join(tmp, "bin"); + const home = join(tmp, "home"); const npmrc = join(tmp, "user.npmrc"); const calls = join(tmp, "npm-calls.txt"); const installArgs = join(tmp, "npm-install-args.txt"); mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); writeFileSync(npmrc, "min-release-age=7\n"); const fakeNpm = join(bin, "npm"); writeFileSync( @@ -194,10 +377,11 @@ describe("install.sh", () => { 'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"', ].join("\n"), { + HOME: home, NPM_CONFIG_USERCONFIG: npmrc, NPM_FAKE_CALLS: calls, NPM_FAKE_INSTALL_ARGS: installArgs, - PATH: `${bin}:${process.env.PATH}`, + PATH: `${bin}:/usr/local/bin:/usr/bin:/bin`, }, ); @@ -638,6 +822,13 @@ describe("install.sh", () => { [ `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, + "type() {", + ' if [[ "$*" == "-P -a node" ]]; then', + ` printf '%s\\n' ${JSON.stringify(staleNode)} ${JSON.stringify(supportedNode)}`, + " return 0", + " fi", + ' builtin type "$@"', + "}", "set +e", "OS=linux", "promote_supported_node_binary",