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",