Files
openclaw/test/scripts/install-sh.test.ts
T
Peter Steinberger 7bc994aee8 fix(install): avoid success after incomplete lifecycle changes (#125992)
* fix(install): make lifecycle mutations transactional

Standalone installers now apply npm-version-aware lifecycle approval. Updates verify and repair the installation before reporting success and preserve the prior install owner during method switches. Uninstall now exits nonzero when requested cleanup is only partially completed. Plugin update behavior is unchanged.

Closes #125925

* test(uninstall): assert aggregated live-owner failure

* fix(install): satisfy standalone shell checks

* fix(update): scan PATH for prior Git wrapper

* test(hooks): await Gmail watcher descendant exit

* fix(install): verify Windows npm candidate

* fix(ci): normalize package acceptance version

* fix(update): preserve staged local package links

* test(update): fold staged symlink coverage

* fix(update): retire every legacy Git wrapper

* test(docs): align consolidated ownership checks
2026-08-18 20:50:15 -07:00

3753 lines
135 KiB
TypeScript

// Install Sh tests cover install sh script behavior.
import { spawnSync } from "node:child_process";
import {
chmodSync,
chownSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { isSupportedOpenClawNodeVersion } from "../../node-version.mjs";
import { NODE_RELEASE_VERSION_CASES } from "../helpers/node-version-cases.js";
import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
writeNpmInstallRetryFixture,
writeNpmLifecycleFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install.sh";
function runInstallShell(script: string, env: NodeJS.ProcessEnv = {}) {
const home = mkdtempSync(join(tmpdir(), "openclaw-install-home-"));
try {
return spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: {
...process.env,
HOME: home,
...env,
BASH_ENV: "",
ENV: "",
OPENCLAW_INSTALL_SH_NO_RUN: "1",
},
});
} finally {
rmSync(home, { force: true, recursive: true });
}
}
describe("install.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
it("runs installer snippets without inherited shell startup files", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-shell-env-"));
const bashEnvPath = join(tmp, "bash_env");
writeFileSync(bashEnvPath, "export OPENCLAW_BASH_ENV_LEAKED=1\n");
try {
const result = runInstallShell('printf "leaked=%s\\n" "${OPENCLAW_BASH_ENV_LEAKED:-0}"', {
BASH_ENV: bashEnvPath,
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("leaked=0\n");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("removes a downloaded script temp file when remote execution fails", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-remote-cleanup-"));
const tempFile = join(tmp, "remote-script.sh");
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
'mktemp() { : > "$PROBE_PATH"; printf \'%s\\n\' "$PROBE_PATH"; }',
"download_file() { printf '#!/bin/bash\\nexit 42\\n' > \"$2\"; }",
'run_remote_bash "https://example.invalid/setup.sh"',
].join("\n"),
{ PROBE_PATH: tempFile },
);
expect(result.status).toBe(42);
expect(existsSync(tempFile)).toBe(false);
expect(script).not.toMatch(/\$\(\s*mktempfile\s*\)/);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("rejects malformed managed scripts without rendering their content", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-script-validation-"));
writeFileSync(join(tmp, "empty.sh"), "");
writeFileSync(join(tmp, "html.sh"), "<html><body>unexpected response</body></html>\n");
writeFileSync(join(tmp, "nul-prefix.sh"), Buffer.from("\0#!/bin/bash\necho unexpected\n"));
writeFileSync(join(tmp, "valid.sh"), "#!/bin/bash\necho valid\n");
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
"for fixture in empty.sh html.sh nul-prefix.sh; do",
' if validate_downloaded_script "$FIXTURE_DIR/$fixture" "https://example.invalid/$fixture"; then',
' printf "unexpectedly accepted: %s\\n" "$fixture"',
" exit 91",
" fi",
"done",
'validate_downloaded_script "$FIXTURE_DIR/valid.sh" "https://example.invalid/valid.sh"',
].join("\n"),
{ FIXTURE_DIR: tmp },
);
expect(result.status).toBe(0);
expect(result.stdout + result.stderr).not.toContain("unexpected response");
expect(result.stdout + result.stderr).not.toContain("echo unexpected");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not execute a shebang-prefixed partial file after download failure", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-partial-download-"));
const marker = join(tmp, "executed");
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
'download_file() { printf \'#!/bin/bash\\n: > "$EXECUTION_MARKER"\\n\' > "$2"; return 23; }',
"set +e",
'run_remote_bash "https://example.invalid/partial.sh"',
"status=$?",
"set -e",
'printf "status=%s\\n" "$status"',
'[[ "$status" -ne 0 ]]',
].join("\n"),
{ EXECUTION_MARKER: marker },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
expect(existsSync(marker)).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("denies redirects for managed script downloads", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-managed-download-"));
try {
const result = runInstallShell(
`
set -euo pipefail
source "${SCRIPT_PATH}"
curl() { printf 'curl=%s\n' "$*"; }
wget() { printf 'wget=%s\n' "$*"; }
DOWNLOADER=curl
download_file "https://example.invalid/setup.sh" "$DOWNLOAD_DIR/curl-setup.sh" deny
DOWNLOADER=wget
download_file "https://example.invalid/setup.sh" "$DOWNLOAD_DIR/wget-setup.sh" deny
download_file() {
printf 'managed-mode=%s\n' "\${3:-}"
printf '#!/bin/bash\n' > "$2"
}
download_validated_script "https://example.invalid/setup.sh" "$DOWNLOAD_DIR/managed-setup.sh"
`,
{ DOWNLOAD_DIR: tmp },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("curl=-fsSL --max-redirs 0");
expect(result.stdout).toContain("wget=-q --max-redirect=0");
expect(result.stdout).toContain("managed-mode=deny");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("bounds stalled curl downloads and propagates timeout failures", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
curl() {
printf 'curl=%s\n' "$*"
return 28
}
DOWNLOADER=curl
set +e
download_file "https://example.invalid/archive.tgz" "/tmp/archive.tgz"
printf 'status=%s\n' "$?"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--speed-limit 1 --speed-time 30");
expect(result.stdout).not.toContain("--connect-timeout");
expect(result.stdout).not.toContain("--max-redirs");
expect(result.stdout).toContain("--retry 3 --retry-delay 1 --retry-connrefused");
expect(result.stdout).toContain("status=28");
});
it.each(["apt-get", "dnf", "yum"])(
"rejects an invalid NodeSource response before %s repository setup",
(packageManager) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-nodesource-validation-"));
const marker = join(tmp, "configured");
try {
const result = runInstallShell(
`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
PACKAGE_MANAGER="$PACKAGE_MANAGER_UNDER_TEST"
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
command() {
if [[ "\${1:-}" == "-v" ]]; then
case "\${2:-}" in
pacman|apk) return 1 ;;
apt-get|dnf|yum) [[ "$PACKAGE_MANAGER" == "$2" ]]; return ;;
esac
fi
builtin command "$@"
}
download_file() {
printf '<html>unexpected response</html>\n' > "$2"
}
ui_info() { printf 'info:%s\n' "$*"; }
ui_success() { :; }
ui_error() { printf 'error:%s\n' "$*"; }
run_quiet_step() {
local title="$1"
shift
printf 'step:%s|%s\n' "$title" "$*"
if [[ "$title" == "Downloading NodeSource setup script" ]]; then
"$@"
return
fi
: > "$EXECUTION_MARKER"
return 0
}
install_node
`,
{ EXECUTION_MARKER: marker, PACKAGE_MANAGER_UNDER_TEST: packageManager },
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("step:Downloading NodeSource setup script");
expect(result.stdout).not.toContain("unexpected response");
expect(existsSync(marker)).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
},
);
it("runs apt-get through noninteractive wrappers", () => {
expect(script).toContain("apt_get()");
expect(script).toContain('DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"');
expect(script).toContain('NEEDRESTART_MODE="${NEEDRESTART_MODE:-a}"');
expect(script).toContain("sudo env DEBIAN_FRONTEND=");
expect(script).toContain("-o Dpkg::Options::=--force-confdef");
expect(script).toContain("-o Dpkg::Options::=--force-confold");
const rawAptInstalls = script
.split("\n")
.filter((line) => /\b(?:sudo\s+)?apt-get\s+install\b/.test(line));
expect(rawAptInstalls).toStrictEqual([]);
});
it("rejects unknown installer options", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
parse_args --bogus
`);
expect(result.status).toBe(2);
expect(result.stdout + result.stderr).toContain("Unknown option: --bogus");
});
it("rejects installer options with missing values", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
parse_args --version --no-onboard
`);
expect(result.status).toBe(2);
expect(result.stdout + result.stderr).toContain("Missing value for --version");
});
it("writes git install wrappers with the resolved Node runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
tmp="$(mktemp -d)"
repo="$tmp/repo"
node_dir="node-bin"
cd "$tmp"
mkdir -p "$repo/.git" "$repo/dist" "$node_dir"
repo="$(cd "$repo" && pwd -P)"
printf 'process.stdout.write("fixture-version\\n");\n' > "$repo/dist/entry.js"
cat > "$node_dir/node" <<'NODE'
#!/usr/bin/env bash
printf 'fake-node:%s\\n' "$*"
NODE
chmod +x "$node_dir/node"
PATH="$node_dir:/usr/bin:/bin"
export PATH
OS=macos
check_git() { return 0; }
ensure_pnpm() { :; }
ensure_pnpm_binary_for_scripts() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }
checkout_git_openclaw_ref() { :; }
cleanup_legacy_submodules() { :; }
activate_repo_pnpm_version() { :; }
git_install_lockfile_flag() { printf '%s\\n' '--frozen-lockfile'; }
run_quiet_step() { return 0; }
ensure_user_local_bin_on_path() {
mkdir -p "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
}
ui_info() { :; }
ui_success() { :; }
ui_error() { printf 'error:%s\\n' "$*"; }
git() {
if [[ "$1" == "--git-dir=$repo/.git" && "$2" == "--work-tree=$repo" && "$3" == "rev-parse" && "$6" == "HEAD^{commit}" ]]; then
return 0
fi
if [[ "$1" == "-C" && "$3" == "status" ]]; then
return 0
fi
printf 'unexpected git:%s\\n' "$*" >&2
return 1
}
install_openclaw_from_git "$repo"
wrapper="$HOME/.local/bin/openclaw"
grep -F "$tmp/$node_dir/node" "$wrapper"
cd /
PATH="/usr/bin:/bin" "$wrapper" --version
`);
expect(result.status, JSON.stringify(result)).toBe(0);
expect(result.stdout).toContain("exec ");
expect(result.stdout).toContain("/node-bin/node");
expect(result.stdout).toContain("fake-node:");
expect(result.stdout).toContain("/repo/dist/entry.js --version");
});
it("rejects a git checkout without a commit without modifying it", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
tmp="$(mktemp -d)"
parent="$tmp/parent"
repo="$parent/repo"
git -C "$tmp" init -q parent
git -C "$parent" config user.email test@example.invalid
git -C "$parent" config user.name test
touch "$parent/seed"
git -C "$parent" add seed
git -C "$parent" commit -qm seed
mkdir -p "$repo"
git -C "$repo" init -q
printf 'ref: refs/heads/main\\n' > "$repo/.git/HEAD"
mkdir -p "$repo/.git/refs/heads"
printf '1111111111111111111111111111111111111111\\n' > "$repo/.git/refs/heads/main"
printf 'keep\\n' > "$repo/local.txt"
ui_info() { :; }
ui_error() { :; }
set +e
validate_git_checkout_head "$repo"
status="$?"
set -e
[[ "$status" -eq 1 ]]
[[ -f "$repo/local.txt" ]]
[[ -d "$repo/.git" ]]
`);
expect(result.status).toBe(0);
});
it("publishes fresh Git clones only after success and cleans failed staging directories", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$HOME/transactional-clone"
mkdir -p "$root"
run_quiet_step() {
shift
"$@"
}
ui_error() { :; }
ui_info() { :; }
git() {
local target="\${*: -1}"
mkdir -p "$target/.git"
printf 'complete\\n' > "$target/checkout.marker"
if [[ "$CLONE_MODE" == "failure" ]]; then
return 42
fi
if [[ "$CLONE_MODE" == "concurrent" ]]; then
mkdir -p "$CONCURRENT_REPO"
printf 'keep\\n' > "$CONCURRENT_REPO/user.marker"
fi
if [[ "$CLONE_MODE" == "retarget-alias" ]]; then
[[ "$(dirname "$target")" == "$ALIAS_TARGET" ]]
rm "$ALIAS_PATH"
ln -s "$ALIAS_REPLACEMENT" "$ALIAS_PATH"
fi
}
CLONE_MODE=success
success_repo="$root/success"
clone_git_checkout_transactionally https://example.invalid/openclaw.git "$success_repo" --filter=blob:none
[[ -f "$success_repo/checkout.marker" ]]
CLONE_MODE=failure
failed_repo="$root/failure"
set +e
clone_git_checkout_transactionally https://example.invalid/openclaw.git "$failed_repo"
failure_status="$?"
set -e
[[ "$failure_status" -eq 42 ]]
[[ ! -e "$failed_repo" ]]
CLONE_MODE=retarget-alias
ALIAS_TARGET="$root/alias-target"
ALIAS_REPLACEMENT="$root/alias-replacement"
ALIAS_PATH="$root/alias"
mkdir -p "$ALIAS_TARGET" "$ALIAS_REPLACEMENT"
ln -s "$ALIAS_TARGET" "$ALIAS_PATH"
clone_git_checkout_transactionally https://example.invalid/openclaw.git "$ALIAS_PATH"
[[ -f "$ALIAS_TARGET/checkout.marker" ]]
[[ -z "$(ls -A "$ALIAS_REPLACEMENT")" ]]
[[ -z "$(find "$ALIAS_TARGET" -maxdepth 1 -name '.openclaw-clone.*' -print -quit)" ]]
CLONE_MODE=concurrent
CONCURRENT_REPO="$root/concurrent"
set +e
clone_git_checkout_transactionally https://example.invalid/openclaw.git "$CONCURRENT_REPO"
concurrent_status="$?"
set -e
[[ "$concurrent_status" -eq 1 ]]
[[ "$(cat "$CONCURRENT_REPO/user.marker")" == "keep" ]]
[[ ! -e "$CONCURRENT_REPO/checkout.marker" ]]
cleanup_tmpfiles
[[ -z "$(find "$root" -maxdepth 1 -name '.openclaw-clone.*' -print -quit)" ]]
`);
expect(result.status, result.stderr || result.stdout).toBe(0);
});
it("keeps the full Git install on the canonical checkout after an alias is retargeted", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$HOME/retargeted-alias"
target="$root/target"
replacement="$root/replacement"
alias_path="$root/alias"
mkdir -p "$target" "$replacement"
target="$(cd "$target" && pwd -P)"
replacement="$(cd "$replacement" && pwd -P)"
ln -s "$target" "$alias_path"
check_git() { return 0; }
ensure_pnpm() { :; }
ensure_pnpm_binary_for_scripts() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }
checkout_git_openclaw_ref() { [[ "$1" == "$target" && "$2" == "main" ]]; }
cleanup_legacy_submodules() { [[ "$1" == "$target" ]]; }
activate_repo_pnpm_version() { [[ "$1" == "$target" ]]; }
git_install_lockfile_flag() {
[[ "$1" == "$target" ]]
printf '%s\\n' '--frozen-lockfile'
}
run_pnpm() {
[[ "$1" == "-C" && "$2" == "$target" ]]
if [[ "\${3:-}" == "build" ]]; then
mkdir -p "$target/dist"
printf '%s\n' 'process.stdout.write("fixture-version\\n");' > "$target/dist/entry.js"
fi
}
run_quiet_step() {
shift
"$@"
}
ensure_user_local_bin_on_path() { mkdir -p "$HOME/.local/bin"; }
ui_info() { :; }
ui_success() { :; }
ui_error() { printf 'error:%s\\n' "$*"; }
git() {
if [[ "$1" == "clone" ]]; then
local clone_target="\${*: -1}"
mkdir -p "$clone_target/.git"
printf 'complete\\n' > "$clone_target/checkout.marker"
rm "$alias_path"
ln -s "$replacement" "$alias_path"
return 0
fi
[[ "$1" == "-C" && "$2" == "$target" ]]
}
install_openclaw_from_git "$alias_path"
grep -F "$target/dist/entry.js" "$HOME/.local/bin/openclaw"
[[ -z "$(ls -A "$replacement")" ]]
[[ -z "$(find "$target" -maxdepth 1 -name '.openclaw-clone.*' -print -quit)" ]]
`);
expect(result.status, result.stderr || result.stdout).toBe(0);
});
it("accepts GNU and musl Linux shells in OS detection", () => {
expect(script).toContain('[[ "$OSTYPE" == "linux"* ]]');
expect(script).not.toContain('[[ "$OSTYPE" == "linux-gnu"* ]]');
});
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_required_step "Installing Node.js" apk add --no-cache nodejs npm',
);
expect(script).toContain(
'run_required_step "Installing Node.js" sudo apk add --no-cache nodejs npm',
);
expect(script).toContain(
'run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm',
);
expect(script).toContain("if ! node_is_supported; 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);
});
it("propagates package manager failure out of install_build_tools_linux", () => {
// PATH="" hides real package managers so only the stubbed function below is
// discoverable, keeping the selected branch identical on macOS and Linux.
for (const packageManager of ["apt-get", "dnf", "yum", "apk", "pacman"]) {
const result = runInstallShell(`
set -uo pipefail
source "${SCRIPT_PATH}"
PATH=""
require_sudo() { :; }
is_root() { return 0; }
is_arch_linux() { [[ "${packageManager}" == "pacman" ]]; }
is_alpine_linux() { [[ "${packageManager}" == "apk" ]]; }
ui_warn() { printf 'warn:%s\\n' "$*"; }
${packageManager}() { :; }
run_quiet_step() { return 1; }
if install_build_tools_linux; then
printf 'result:success\\n'
else
printf 'result:failure\\n'
fi
`);
expect(result.stdout, packageManager).toContain("result:failure");
expect(result.stdout, packageManager).not.toContain("result:success");
}
});
it("uses the apk Node.js installer path on Alpine", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
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_supported() { return 0; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("info:Installing Node.js via apk (Alpine Linux detected)");
expect(result.stdout).toContain("step:Installing Node.js|apk add --no-cache nodejs npm");
expect(result.stdout).toContain("finish-linux-node");
expect(result.stdout).not.toContain("Installing Node.js via NodeSource");
});
it("ignores an unrelated pacman command on Debian", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_arch_linux() { return 1; }
is_alpine_linux() { return 1; }
pacman() { printf 'pacman:%s\\n' "$*"; }
apt-get() { :; }
download_validated_script() { :; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { :; }
run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("info:Installing Node.js via NodeSource");
expect(result.stdout).toContain("step:Installing Node.js|apt_get_install nodejs");
expect(result.stdout).toContain("finish-linux-node");
expect(result.stdout).not.toContain("pacman:");
});
it("uses pacman for Node.js on Arch Linux", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_arch_linux() { return 0; }
pacman() { :; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { :; }
run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
"info:Installing Node.js via pacman (Arch-based distribution detected)",
);
expect(result.stdout).toContain("step:Installing Node.js|pacman -Sy --noconfirm nodejs npm");
expect(result.stdout).toContain("finish-linux-node");
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.3
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 guidance when apk cannot provide a safe SQLite runtime", () => {
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 with WAL-reset-safe SQLite",
);
expect(result.stdout).toContain(
"Use an official node:26-alpine container or a glibc-based host",
);
});
it("stops when NodeSource repository setup fails", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 1; }
apt-get() { :; }
download_validated_script() { return 0; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
if [[ "$1" == "Configuring NodeSource repository" ]]; then
return 64
fi
return 0
}
node() {
if [[ "\${1:-}" == "-v" ]]; then
printf 'v24.0.0\\n'
fi
}
activate_supported_node_on_path() { :; }
if install_node; then
echo "install_node returned success"
fi
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("step:Configuring NodeSource repository|bash");
expect(result.stdout).not.toContain("step:Installing Node.js|apt_get_install nodejs");
expect(result.stdout).not.toContain("success:Node.js v24.0.0 installed");
expect(result.stdout).not.toContain("install_node returned success");
});
it("stops when apt cannot install the Node.js package", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 1; }
apt-get() { :; }
download_validated_script() { return 0; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
if [[ "$1" == "Installing Node.js" ]]; then
return 65
fi
return 0
}
node() {
if [[ "\${1:-}" == "-v" ]]; then
printf 'v24.0.0\\n'
fi
}
activate_supported_node_on_path() { :; }
if install_node; then
echo "install_node returned success"
fi
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("step:Configuring NodeSource repository|bash");
expect(result.stdout).toContain("step:Installing Node.js|apt_get_install nodejs");
expect(result.stdout).not.toContain("success:Node.js v24.0.0 installed");
expect(result.stdout).not.toContain("install_node returned success");
});
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"');
expect(script).toContain('npm_config_has_raw_key "$npm_cmd" "min-release-age"');
expect(script).toContain('freshness_flag="--before=$(date -u');
expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g)');
});
it.each([
{ expected: false, version: "11.15.0" },
{ expected: true, version: "11.16.0" },
{ expected: true, version: "12.0.0" },
])("applies canonical npm lifecycle policy for npm $version", ({ expected, version }) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-lifecycle-"));
const npm = join(tmp, "npm");
const args = join(tmp, "args");
const npmRoot = join(tmp, "lib", "node_modules");
const packageDir = join(npmRoot, "openclaw");
writeNpmLifecycleFixture(npm);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_command_path() { printf '%s\\n' ${JSON.stringify(npm)}; }`,
`run_verified_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "log"))}`,
].join("\n"),
{
NPM_FAKE_ARGS: args,
NPM_FAKE_PACKAGE_DIR: packageDir,
NPM_FAKE_ROOT: npmRoot,
NPM_FAKE_VERSION: version,
},
);
expect(result.status).toBe(0);
expect(readFileSync(args, "utf8").includes("--allow-scripts=openclaw")).toBe(expected);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("fails before npm mutation on invalid versions and rejects a remaining guard", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-lifecycle-fail-"));
const npm = join(tmp, "npm");
const args = join(tmp, "args");
const npmRoot = join(tmp, "lib", "node_modules");
writeNpmLifecycleFixture(npm);
try {
const run = (version: string, keepGuard: string) =>
runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_command_path() { printf '%s\\n' ${JSON.stringify(npm)}; }`,
`run_verified_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "log"))}`,
].join("\n"),
{
NPM_FAKE_ARGS: args,
NPM_FAKE_KEEP_GUARD: keepGuard,
NPM_FAKE_PACKAGE_DIR: join(npmRoot, "openclaw"),
NPM_FAKE_ROOT: npmRoot,
NPM_FAKE_VERSION: version,
},
);
expect(run("invalid", "0").status).not.toBe(0);
expect(existsSync(args)).toBe(false);
expect(run("12.0.0", "1").status).not.toBe(0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("relativizes absolute npm path identities against the command cwd", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-lifecycle-comma,"));
const npm = join(tmp, "npm");
const commandCwd = join(tmp, "work");
const candidate = join(tmp, "candidate.tgz");
mkdirSync(commandCwd);
writeNpmLifecycleFixture(npm);
try {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`cd ${JSON.stringify(commandCwd)}`,
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} ${JSON.stringify(candidate)} "$PWD"`,
].join("\n"),
{ NPM_FAKE_VERSION: "12.0.0" },
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("--allow-scripts=../candidate.tgz");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it.each(["success", "guard-failure"])(
"keeps same-bin git-to-npm switching rollback-safe on $mode",
(mode) => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)"
repo="$root/repo"
npm_root="$root/lib/node_modules"
bin="$HOME/.local/bin"
mkdir -p "$repo/dist" "$npm_root/openclaw/dist" "$bin"
printf '%s\n' 'process.stdout.write("git-version\\n")' > "$repo/dist/entry.js"
cat > "$bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec ${process.execPath} $repo/dist/entry.js "\\$@"
EOF
chmod +x "$bin/openclaw"
fake_npm="$root/npm"
cat > "$fake_npm" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "\${1:-}" in
--version) printf '12.0.0\n'; exit 0 ;;
root) printf '%s\n' "$NPM_FAKE_ROOT"; exit 0 ;;
prefix) printf '%s\n' "$NPM_FAKE_PREFIX"; exit 0 ;;
config) printf 'null\n'; exit 0 ;;
esac
mkdir -p "$NPM_FAKE_ROOT/openclaw/dist"
printf '%s\n' '#!/usr/bin/env node' 'process.stdout.write("npm-version\\n")' > "$NPM_FAKE_ROOT/openclaw/openclaw.mjs"
chmod +x "$NPM_FAKE_ROOT/openclaw/openclaw.mjs"
if [[ "$NPM_FAKE_MODE" == guard-failure ]]; then
: > "$NPM_FAKE_ROOT/openclaw/dist/openclaw-install-guard"
else
rm -f "$NPM_FAKE_ROOT/openclaw/dist/openclaw-install-guard"
fi
EOF
chmod +x "$fake_npm"
npm() { "$fake_npm" "$@"; }
npm_command_path() { printf '%s\n' "$fake_npm"; }
npm_global_bin_dir() { printf '%s\n' "$bin"; }
GIT_DIR="$repo"
OPENCLAW_VERSION="$root/candidate.tgz"
export NPM_FAKE_ROOT="$npm_root" NPM_FAKE_PREFIX="$HOME/.local" NPM_FAKE_MODE=${mode}
prepare_git_wrapper_backup_for_npm "$GIT_DIR"
set +e
install_openclaw
status=$?
set -e
cleanup_tmpfiles
printf 'status=%s version=%s link=%s\n' "$status" "$("$bin/openclaw" --version)" "$([[ -L "$bin/openclaw" ]] && echo yes || echo no)"
`);
expect(result.status).toBe(0);
if (mode === "success") {
expect(result.stdout, result.stderr).toContain("status=0 version=npm-version link=yes");
} else {
expect(result.stdout, result.stderr).toContain("status=1 version=git-version link=no");
}
},
);
it("restores an active shim backup when installation is interrupted", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-shim-signal-"));
const target = join(tmp, "openclaw");
writeFileSync(target, "original-wrapper\n", { mode: 0o755 });
try {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
'begin_openclaw_bin_backup "$BACKUP_TARGET" "$BACKUP_CANDIDATE" 1',
'kill -TERM "$$"',
].join("\n"),
{ BACKUP_CANDIDATE: join(tmp, "openclaw.mjs"), BACKUP_TARGET: target },
);
expect(result.status).toBe(143);
expect(readFileSync(target, "utf8")).toBe("original-wrapper\n");
expect(readdirSync(tmp)).toEqual(["openclaw"]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("removes only stale npm rename directories before ENOTEMPTY retry", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)/node_modules"
mkdir -p "$root/openclaw" "$root/.openclaw-stale"
printf 'live\n' > "$root/openclaw/marker"
npm() { [[ "$1" == root ]] && printf '%s\n' "$root"; }
run_npm_global_install() {
attempts=$((attempts + 1))
if (( attempts == 1 )); then printf 'ENOTEMPTY: directory not empty, rename openclaw\n' > "$2"; return 1; fi
return 0
}
auto_install_build_tools_for_npm_failure() { return 1; }
attempts=0
install_openclaw_npm openclaw@latest
[[ -f "$root/openclaw/marker" && ! -e "$root/.openclaw-stale" ]]
`);
expect(result.status).toBe(0);
});
it("does not report npm owner retirement when uninstall fails", () => {
const result = runInstallShell(`
source "${SCRIPT_PATH}"
root="$(mktemp -d)/node_modules"
mkdir -p "$root/openclaw"
printf '{"name":"openclaw"}\n' > "$root/openclaw/package.json"
fake_npm="$root/npm"
printf '#!/bin/sh\nif [ "$1" = root ]; then echo "$NPM_ROOT"; exit 0; fi\nexit 9\n' > "$fake_npm"
chmod +x "$fake_npm"
export NPM_ROOT="$root"
npm_command_path() { printf '%s\n' "$fake_npm"; }
npm_global_bin_dir() { printf '/different/bin\n'; }
set +e
retire_npm_owner_after_git_install
status=$?
set -e
printf 'status=%s\n' "$status"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
expect(result.stdout).not.toContain("Previous npm install retired");
});
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(
fakeNpm,
[
"#!/usr/bin/env bash",
'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"',
'if [[ "$1" == "config" && "$2" == "get" ]]; then',
' if [[ "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
" fi",
' if [[ "$3" == "before" ]]; then',
" printf '2026-01-01T00:00:00.000Z\\n'",
" exit 0",
" fi",
"fi",
'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"',
"exit 0",
"",
].join("\n"),
);
chmodSync(fakeNpm, 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'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}:/usr/local/bin:/usr/bin:/bin`,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--min-release-age=0");
expect(result.stdout).not.toContain("--before=");
expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n");
expect(readFileSync(installArgs, "utf8")).not.toContain("--before=");
expect(readFileSync(calls, "utf8")).not.toContain("config get before");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit --before when default global npmrc config contains min-release-age", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-npmrc-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const prefix = join(tmp, "prefix");
const npmrc = join(prefix, "etc", "npmrc");
const calls = join(tmp, "npm-calls.txt");
const installArgs = join(tmp, "npm-install-args.txt");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(join(prefix, "etc"), { recursive: true });
writeFileSync(npmrc, "min-release-age=7\n");
const fakeNpm = join(bin, "npm");
writeFileSync(
fakeNpm,
[
"#!/usr/bin/env bash",
'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"',
'if [[ "$1" == "config" && "$2" == "get" ]]; then',
' if [[ "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
" fi",
' if [[ "$3" == "globalconfig" ]]; then',
' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"',
" exit 0",
" fi",
' if [[ "$3" == "before" ]]; then',
" printf '2026-01-01T00:00:00.000Z\\n'",
" exit 0",
" fi",
"fi",
'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"',
"exit 0",
"",
].join("\n"),
);
chmodSync(fakeNpm, 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
{
HOME: home,
NPM_CONFIG_GLOBALCONFIG: undefined,
NPM_CONFIG_PREFIX: undefined,
npm_config_globalconfig: undefined,
npm_config_prefix: undefined,
NPM_FAKE_CALLS: calls,
NPM_FAKE_GLOBALCONFIG: npmrc,
NPM_FAKE_INSTALL_ARGS: installArgs,
PATH: `${bin}:${process.env.PATH}`,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--min-release-age=0");
expect(result.stdout).not.toContain("--before=");
expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n");
expect(readFileSync(installArgs, "utf8")).not.toContain("--before=");
expect(readFileSync(calls, "utf8")).not.toContain("config get before");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit --before when builtin npmrc config contains min-release-age", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-builtin-npmrc-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const npmrc = join(tmp, "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(
fakeNpm,
[
"#!/usr/bin/env bash",
'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"',
'if [[ "$1" == "config" && "$2" == "get" ]]; then',
' if [[ "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
" fi",
' if [[ "$3" == "globalconfig" ]]; then',
' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"',
" exit 0",
" fi",
' if [[ "$3" == "before" ]]; then',
" printf '2026-01-01T00:00:00.000Z\\n'",
" exit 0",
" fi",
"fi",
'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"',
"exit 0",
"",
].join("\n"),
);
chmodSync(fakeNpm, 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
{
HOME: home,
NPM_CONFIG_GLOBALCONFIG: undefined,
NPM_CONFIG_PREFIX: undefined,
npm_config_globalconfig: undefined,
npm_config_prefix: undefined,
NPM_FAKE_CALLS: calls,
NPM_FAKE_GLOBALCONFIG: join(tmp, "missing-global-npmrc"),
NPM_FAKE_INSTALL_ARGS: installArgs,
PATH: `${bin}:${process.env.PATH}`,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--min-release-age=0");
expect(result.stdout).not.toContain("--before=");
expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n");
expect(readFileSync(installArgs, "utf8")).not.toContain("--before=");
expect(readFileSync(calls, "utf8")).not.toContain("config get before");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("uses OPENCLAW_HOME for git defaults", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-home-"));
const osHome = join(tmp, "os-home");
const openclawHome = join(tmp, "openclaw-home");
mkdirSync(osHome, { recursive: true });
mkdirSync(openclawHome, { recursive: true });
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
'printf "git=%s\\n" "$GIT_DIR"',
].join("\n"),
{
HOME: osHome,
OPENCLAW_HOME: openclawHome,
OPENCLAW_GIT_DIR: undefined,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const output = result?.stdout ?? "";
expect(output).toContain(`git=${join(openclawHome, "openclaw")}`);
});
it("uses a blobless partial clone for new git installs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
repo="$HOME/openclaw"
mkdir -p "$repo"
repo="$(cd "$repo" && pwd -P)"
check_git() { return 0; }
ensure_pnpm() { :; }
ensure_pnpm_binary_for_scripts() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }
checkout_git_openclaw_ref() { :; }
cleanup_legacy_submodules() { :; }
activate_repo_pnpm_version() { :; }
git_install_lockfile_flag() { printf '%s\\n' '--frozen-lockfile'; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
if [[ "$1" == "Cloning OpenClaw" ]]; then
target="\${*: -1}"
mkdir -p "$target/.git"
printf 'complete\\n' > "$target/checkout.marker"
elif [[ "$1" == "Building OpenClaw" ]]; then
mkdir -p "$repo/dist"
printf '%s\\n' 'process.stdout.write("fixture-version\\n");' > "$repo/dist/entry.js"
fi
return 0
}
ensure_user_local_bin_on_path() { mkdir -p "$HOME/.local/bin"; }
ui_info() { :; }
ui_success() { :; }
ui_error() { printf 'error:%s\\n' "$*"; }
git() { return 0; }
install_openclaw_from_git "$repo"
`);
expect(result.status, JSON.stringify(result)).toBe(0);
expect(result.stdout).toContain(
"step:Cloning OpenClaw|git clone --filter=blob:none https://github.com/openclaw/openclaw.git",
);
expect(result.stdout).toContain("/.openclaw-clone.");
});
it("does not treat OS HOME config as active when OPENCLAW_HOME is set", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-legacy-config-"));
const osHome = join(tmp, "os-home");
const openclawHome = join(tmp, "openclaw-home");
const legacyConfigDir = join(osHome, ".openclaw");
mkdirSync(legacyConfigDir, { recursive: true });
mkdirSync(openclawHome, { recursive: true });
writeFileSync(join(legacyConfigDir, "openclaw.json"), "{}\n");
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
'if has_openclaw_config; then printf "configured=1\\n"; else printf "configured=0\\n"; fi',
].join("\n"),
{
HOME: osHome,
OPENCLAW_HOME: openclawHome,
OPENCLAW_CONFIG_PATH: undefined,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain("configured=0");
expect(result?.stderr ?? "").toBe("");
});
it.each(["openclaw.json", "clawdbot.json"])(
"detects %s under OPENCLAW_STATE_DIR",
(configName) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-state-config-"));
const stateDir = join(tmp, "state");
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, configName), "{}\n");
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
'if has_openclaw_config; then printf "configured=1\\n"; else printf "configured=0\\n"; fi',
].join("\n"),
{
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_STATE_DIR: stateDir,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain("configured=1");
expect(result?.stderr ?? "").toBe("");
},
);
it("does not fall back to home config when OPENCLAW_STATE_DIR is set", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-state-override-"));
const home = join(tmp, "home");
const stateDir = join(tmp, "state");
mkdirSync(join(home, ".openclaw"), { recursive: true });
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(home, ".openclaw", "openclaw.json"), "{}\n");
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
'if has_openclaw_config; then printf "configured=1\\n"; else printf "configured=0\\n"; fi',
].join("\n"),
{
HOME: home,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: stateDir,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain("configured=0");
expect(result?.stderr ?? "").toBe("");
});
it.each([
{
expected: /No TTY; run .*\/\.local\/bin\/openclaw onboard to finish setup/,
name: "starts setup",
noOnboard: 0,
},
{
expected: /Skipping onboard .*run .*\/\.local\/bin\/openclaw onboard later/,
name: "honors --no-onboard",
noOnboard: 1,
},
])(
"$name for an unconfigured git install replacing an existing binary",
({ expected, noOnboard }) => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
INSTALL_METHOD=git
GIT_DIR="$HOME/openclaw"
NO_ONBOARD=${noOnboard}
NO_PROMPT=1
VERIFY_INSTALL=1
OS=linux
bootstrap_gum_temp() { :; }
print_installer_banner() { :; }
print_gum_status() { :; }
detect_os_or_die() { OS=linux; }
detect_openclaw_checkout() { return 1; }
show_install_plan() { :; }
check_existing_openclaw() { return 0; }
load_nvm_for_node_detection() { :; }
check_node() { return 0; }
activate_supported_node_on_path() { :; }
ensure_default_node_active_shell() { return 0; }
npm() { return 1; }
install_openclaw_from_git() {
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\\nexit 0\\n' > "$HOME/.local/bin/openclaw"
chmod +x "$HOME/.local/bin/openclaw"
export PATH="$HOME/.local/bin:$PATH"
}
resolve_openclaw_bin() { printf '%s\\n' "$HOME/.local/bin/openclaw"; }
warn_duplicate_openclaw_global_installs() { :; }
npm_global_bin_dir() { :; }
warn_shell_path_missing_dir() { :; }
refresh_gateway_service_if_loaded() { printf 'gateway-refresh-called\\n'; }
run_doctor() {
printf 'doctor-called\\n'
return 0
}
resolve_openclaw_version() { printf 'test-version\\n'; }
is_gateway_daemon_loaded() {
printf 'gateway-probe-called\\n'
return 1
}
maybe_open_dashboard() { :; }
show_footer_links() { :; }
main
`);
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("doctor-called");
expect(result.stdout).not.toContain("gateway-refresh-called");
expect(result.stdout).not.toContain("gateway-probe-called");
expect(result.stdout).toMatch(/Update command:.*\/\.local\/bin\/openclaw update/);
expect(result.stdout).toMatch(expected);
},
);
it("honors --verify for an unconfigured install without a TTY", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
INSTALL_METHOD=git
GIT_DIR="$HOME/openclaw"
NO_ONBOARD=0
NO_PROMPT=1
VERIFY_INSTALL=1
OS=linux
bootstrap_gum_temp() { :; }
print_installer_banner() { :; }
print_gum_status() { :; }
detect_os_or_die() { OS=linux; }
detect_openclaw_checkout() { return 1; }
show_install_plan() { :; }
check_existing_openclaw() { return 0; }
load_nvm_for_node_detection() { :; }
check_node() { return 0; }
activate_supported_node_on_path() { :; }
ensure_default_node_active_shell() { return 0; }
npm() { return 1; }
install_openclaw_from_git() {
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\\nexit 1\\n' > "$HOME/.local/bin/openclaw"
chmod +x "$HOME/.local/bin/openclaw"
export PATH="$HOME/.local/bin:$PATH"
}
resolve_openclaw_bin() { printf '%s\\n' "$HOME/.local/bin/openclaw"; }
warn_duplicate_openclaw_global_installs() { :; }
npm_global_bin_dir() { :; }
warn_shell_path_missing_dir() { :; }
refresh_gateway_service_if_loaded() { :; }
resolve_openclaw_version() { printf 'test-version\\n'; }
maybe_open_dashboard() { :; }
show_footer_links() { :; }
main
`);
expect(result.status).toBe(1);
expect(result.stdout).toMatch(/No TTY; run .*\/\.local\/bin\/openclaw onboard to finish setup/);
});
it("runs migration doctor for a configured upgrade without a TTY", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
INSTALL_METHOD=npm
NO_ONBOARD=0
NO_PROMPT=0
OS=linux
mkdir -p "$HOME/.openclaw"
printf '{}\\n' > "$HOME/.openclaw/openclaw.json"
bootstrap_gum_temp() { :; }
print_installer_banner() { :; }
print_gum_status() { :; }
detect_os_or_die() { OS=linux; }
detect_openclaw_checkout() { return 1; }
show_install_plan() { :; }
check_existing_openclaw() { return 0; }
load_nvm_for_node_detection() { :; }
check_node() { return 0; }
activate_supported_node_on_path() { :; }
ensure_default_node_active_shell() { return 0; }
check_git() { return 0; }
fix_npm_permissions() { :; }
prepare_git_wrapper_backup_for_npm() { :; }
install_openclaw() {
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\\nexit 0\\n' > "$HOME/.local/bin/openclaw"
chmod +x "$HOME/.local/bin/openclaw"
export PATH="$HOME/.local/bin:$PATH"
}
resolve_openclaw_bin() { printf '%s\\n' "$HOME/.local/bin/openclaw"; }
warn_duplicate_openclaw_global_installs() { :; }
npm_global_bin_dir() { :; }
warn_shell_path_missing_dir() { :; }
refresh_gateway_service_if_loaded() { :; }
run_doctor() {
printf 'doctor-called\\n'
return 0
}
resolve_openclaw_version() { printf 'test-version\\n'; }
is_gateway_daemon_loaded() { return 1; }
verify_installation() { return 0; }
maybe_open_dashboard() { printf 'dashboard-called\\n'; }
show_footer_links() { :; }
main
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("doctor-called");
expect(result.stdout).toContain("dashboard-called");
});
it("fails a configured upgrade without printing success when doctor fails", () => {
const result = runInstallShell(`
source "${SCRIPT_PATH}"
INSTALL_METHOD=npm; NO_ONBOARD=1; NO_PROMPT=1; OS=linux
bootstrap_gum_temp() { :; }; print_installer_banner() { :; }; print_gum_status() { :; }
detect_os_or_die() { OS=linux; }; detect_openclaw_checkout() { return 1; }; show_install_plan() { :; }
check_existing_openclaw() { return 0; }; load_nvm_for_node_detection() { :; }; check_node() { return 0; }
activate_supported_node_on_path() { :; }; ensure_default_node_active_shell() { return 0; }
check_git() { return 0; }; fix_npm_permissions() { :; }
prepare_git_wrapper_backup_for_npm() { :; }
install_openclaw() { mkdir -p "$HOME/.local/bin"; printf '#!/bin/sh\nif [ "$1" = doctor ]; then exit 9; fi\nexit 0\n' > "$HOME/.local/bin/openclaw"; chmod +x "$HOME/.local/bin/openclaw"; }
resolve_installed_openclaw_bin() { printf '%s\n' "$HOME/.local/bin/openclaw"; }
warn_duplicate_openclaw_global_installs() { :; }; npm_global_bin_dir() { :; }; warn_shell_path_missing_dir() { :; }
has_openclaw_config() { return 0; }; refresh_gateway_service_if_loaded() { :; }
run_doctor() { return 9; }; resolve_openclaw_version() { printf 'test-version\n'; }
retire_git_wrapper_after_npm_install() { :; }; show_footer_links() { :; }
main
`);
expect(result.status).toBe(9);
expect(result.stdout).not.toContain("installed successfully");
expect(result.stdout).not.toContain("Upgrade complete");
});
it("keeps the npm owner runnable when a npm-to-git candidate fails", () => {
const result = runInstallShell(`
source "${SCRIPT_PATH}"
INSTALL_METHOD=git; GIT_DIR="$HOME/openclaw"; OS=linux
mkdir -p "$HOME/npm-owner"; printf 'working\n' > "$HOME/npm-owner/status"
bootstrap_gum_temp() { :; }; print_installer_banner() { :; }; print_gum_status() { :; }
detect_os_or_die() { OS=linux; }; detect_openclaw_checkout() { return 1; }; show_install_plan() { :; }
check_existing_openclaw() { return 0; }; load_nvm_for_node_detection() { :; }; check_node() { return 0; }
activate_supported_node_on_path() { :; }; ensure_default_node_active_shell() { return 0; }
npm() { if [[ "$1" == list ]]; then return 0; fi; if [[ "$1" == uninstall ]]; then printf 'old-owner-removed\n'; rm -f "$HOME/npm-owner/status"; fi; }
install_openclaw_from_git() { return 7; }
main
`);
expect(result.status).toBe(7);
expect(result.stdout).not.toContain("old-owner-removed");
});
it("rejects OpenClaw GitHub source targets for npm installs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
set +e
OPENCLAW_VERSION=main
USE_BETA=0
install_openclaw
status=$?
printf 'status=%s\\n' "$status"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
expect(result.stdout).toContain("npm installs do not support OpenClaw GitHub source targets");
expect(result.stdout).toContain("--install-method git --version main");
});
it("links the executable package launcher when dist/entry.js is not executable", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-bin-link-"));
const bin = join(tmp, "bin");
const packageDir = join(tmp, "lib", "node_modules", "openclaw");
mkdirSync(join(packageDir, "dist"), { recursive: true });
mkdirSync(bin, { recursive: true });
writeFileSync(join(packageDir, "dist", "entry.js"), "export {};\n");
writeFileSync(
join(packageDir, "openclaw.mjs"),
'#!/usr/bin/env node\nprocess.stdout.write("OpenClaw fixture\\n");\n',
);
chmodSync(join(packageDir, "openclaw.mjs"), 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm() { [[ "$1" == "root" ]] && printf '%s\\n' ${JSON.stringify(join(tmp, "lib", "node_modules"))}; }`,
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"ensure_openclaw_bin_link",
`${JSON.stringify(join(bin, "openclaw"))} --version`,
].join("\n"),
);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(result.stdout).toContain("OpenClaw fixture");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("rejects an installed package whose executable launcher is missing", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-missing-bin-"));
const bin = join(tmp, "bin");
const packageDir = join(tmp, "lib", "node_modules", "openclaw");
mkdirSync(join(packageDir, "dist"), { recursive: true });
mkdirSync(bin, { recursive: true });
writeFileSync(join(packageDir, "dist", "entry.js"), "#!/usr/bin/env node\n");
chmodSync(join(packageDir, "dist", "entry.js"), 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm() { [[ "$1" == "root" ]] && printf '%s\\n' ${JSON.stringify(join(tmp, "lib", "node_modules"))}; }`,
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"ensure_openclaw_bin_link",
].join("\n"),
);
expect(result.status).toBe(1);
expect(existsSync(join(bin, "openclaw"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("rejects an installed package whose launcher fails version validation", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-invalid-bin-"));
const bin = join(tmp, "bin");
const packageDir = join(tmp, "lib", "node_modules", "openclaw");
mkdirSync(packageDir, { recursive: true });
mkdirSync(bin, { recursive: true });
writeFileSync(join(packageDir, "openclaw.mjs"), "#!/bin/sh\nexit 7\n");
chmodSync(join(packageDir, "openclaw.mjs"), 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm() { [[ "$1" == "root" ]] && printf '%s\\n' ${JSON.stringify(join(tmp, "lib", "node_modules"))}; }`,
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"ensure_openclaw_bin_link",
].join("\n"),
);
expect(result.status).not.toBe(0);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it.each([
{ requested: "latest", outcome: "success", error: "", calls: 1, status: 0 },
{
requested: "beta",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "next",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "2026.8.1",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "latest",
outcome: "persistent",
error: "EACCES permission denied",
calls: 2,
status: 1,
},
{
requested: "beta",
outcome: "persistent",
error: "ENOSPC no space left",
calls: 2,
status: 1,
},
])(
"keeps openclaw@$requested immutable across $outcome npm installs",
({ requested, outcome, error, calls: expectedCalls, status: expectedStatus }) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-retry-"));
const bin = join(tmp, "bin");
const calls = join(tmp, "calls");
const npmRoot = join(tmp, "lib", "node_modules");
mkdirSync(bin, { recursive: true });
writeNpmInstallRetryFixture(join(bin, "npm"));
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
`OPENCLAW_VERSION=${requested}`,
"USE_BETA=0",
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"set +e",
"install_openclaw",
"status=$?",
"exit $status",
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: error,
NPM_FAKE_OUTCOME: outcome,
NPM_FAKE_PACKAGE_DIR: join(npmRoot, "openclaw"),
NPM_FAKE_ROOT: npmRoot,
},
);
expect(result.status).toBe(expectedStatus);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual(
Array.from({ length: expectedCalls }, () => `openclaw@${requested}`),
);
if (expectedStatus !== 0) {
expect(`${result.stdout}\n${result.stderr}`).toContain(`${error} (attempt 2)`);
}
if (requested !== "next") {
expect(`${result.stdout}\n${result.stderr}`).not.toContain("openclaw@next");
}
} finally {
rmSync(tmp, { force: true, recursive: true });
}
},
);
it("fails after retrying the exact npm spec when npm exits zero without installing OpenClaw", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-empty-success-"));
const bin = join(tmp, "bin");
const calls = join(tmp, "calls");
const npmRoot = join(tmp, "lib", "node_modules");
mkdirSync(bin, { recursive: true });
writeNpmInstallRetryFixture(join(bin, "npm"));
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"OPENCLAW_VERSION=latest",
"USE_BETA=0",
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"install_openclaw",
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: "",
NPM_FAKE_OUTCOME: "success",
NPM_FAKE_ROOT: npmRoot,
},
);
expect(result.status).toBe(1);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual([
"openclaw@latest",
"openclaw@latest",
]);
expect(`${result.stdout}\n${result.stderr}`).toContain(
"npm install did not produce a usable OpenClaw package",
);
expect(`${result.stdout}\n${result.stderr}`).not.toContain("openclaw@next");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit before args when npmrc min-release-age computes a before cutoff", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-freshness-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".npmrc"), "min-release-age=7\n");
writeNpmFreshnessConflictFixture(join(bin, "npm"), argsLog);
let result: ReturnType<typeof runInstallShell> | undefined;
let argsOutput;
try {
result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`HOME=${JSON.stringify(home)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--min-release-age=0");
expect(argsOutput).not.toContain("--before=");
});
it("ignores project npmrc when choosing global install freshness args", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-freshness-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const project = join(tmp, "project");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n");
writeFileSync(join(project, ".npmrc"), "min-release-age=7\n");
writeNpmBeforePolicyFixture(join(bin, "npm"), argsLog);
let result: ReturnType<typeof runInstallShell> | undefined;
let argsOutput;
try {
result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(project)}`,
`source ${JSON.stringify(process.cwd() + "/" + SCRIPT_PATH)}`,
`HOME=${JSON.stringify(home)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--before=");
expect(argsOutput).not.toContain("--min-release-age=0");
});
it("exports noninteractive apt env during Linux startup", () => {
expect(script).toMatch(
/detect_os_or_die\s+if \[\[ "\$OS" == "linux" \]\]; then\s+export DEBIAN_FRONTEND="\$\{DEBIAN_FRONTEND:-noninteractive\}"\s+export NEEDRESTART_MODE="\$\{NEEDRESTART_MODE:-a\}"\s+fi/m,
);
expect(script).toContain(
'run_required_step "Configuring NodeSource repository" sudo -E bash "$tmp"',
);
});
it("counts the verify stage when --verify is enabled", () => {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
"parse_args --verify",
"configure_install_stage_total",
'ui_stage "Preparing environment"',
'ui_stage "Installing OpenClaw"',
'ui_stage "Finalizing setup"',
'ui_stage "Verifying installation"',
].join("\n"),
{ TERM: "dumb" },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("[4/4] Verifying installation");
expect(result.stdout).not.toContain("[4/3] Verifying installation");
});
it("bounds installer npm prefix probes during finalization helpers", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-probe-"));
const npm = join(tmp, "npm");
writeFileSync(
npm,
[
"#!/usr/bin/env bash",
'if [[ "$1" == "prefix" && "$2" == "-g" ]]; then',
" sleep 2",
" exit 0",
"fi",
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then',
' printf "/tmp/openclaw-npm\\n"',
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
);
chmodSync(npm, 0o755);
try {
const result = runInstallShell(
[`source ${JSON.stringify(SCRIPT_PATH)}`, "npm_global_bin_dir"].join("\n"),
{
OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.1",
PATH: `${tmp}:${process.env.PATH ?? ""}`,
},
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("/tmp/openclaw-npm/bin");
expect(result.stderr).toContain(
"timed out during installer finalization probe: npm prefix -g",
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("bounds daemon status probes during finalization helpers", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-probe-"));
const claw = join(tmp, "openclaw");
writeFileSync(
claw,
[
"#!/usr/bin/env bash",
'if [[ "$1" == "daemon" && "$2" == "status" && "$3" == "--json" ]]; then',
" sleep 2",
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
);
chmodSync(claw, 0o755);
try {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`if is_gateway_daemon_loaded ${JSON.stringify(claw)}; then`,
' printf "loaded\\n"',
"else",
' printf "not-loaded\\n"',
"fi",
].join("\n"),
{ OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.01" },
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("not-loaded");
expect(result.stderr).toContain(
"timed out during installer finalization probe: openclaw daemon status --json",
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("loads nvm before checking Node.js so stale system Node does not win", () => {
expect(script).toMatch(
/# Step 1: Node\.js[\s\S]*?load_nvm_for_node_detection\s+if ! check_node; then/,
);
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-nvm-"));
const home = join(tmp, "home");
const systemBin = join(tmp, "system-bin");
const nvmBin = join(home, ".nvm/versions/node/v22.22.3/bin");
mkdirSync(systemBin, { recursive: true });
mkdirSync(nvmBin, { recursive: true });
mkdirSync(join(home, ".nvm"), { recursive: true });
const systemNode = join(systemBin, "node");
const nvmNode = join(nvmBin, "node");
writeFileSync(systemNode, "#!/bin/sh\necho v8.11.3\n");
writeFileSync(nvmNode, "#!/bin/sh\necho v22.22.3\n");
chmodSync(systemNode, 0o755);
chmodSync(nvmNode, 0o755);
writeFileSync(
join(home, ".nvm/nvm.sh"),
[
'NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
"export NVM_DIR",
"nvm() {",
' if [ "$1" = "use" ]; then',
' export PATH="$NVM_DIR/versions/node/v22.22.3/bin:$PATH"',
" return 0",
" fi",
" return 0",
"}",
"",
].join("\n"),
);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"set +e",
"load_nvm_for_node_detection",
"check_node",
"status=$?",
'printf "status=%s\\npath=%s\\nversion=%s\\n" "$status" "$(command -v node)" "$(node -v)"',
"exit $status",
].join("\n"),
{
HOME: home,
NVM_DIR: join(tmp, "stale-nvm"),
PATH: `${systemBin}:/usr/bin:/bin`,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const output = result?.stdout ?? "";
expect(output).toContain("status=0");
expect(output).toContain(`path=${nvmNode}`);
expect(output).toContain("version=v22.22.3");
});
it("installs Homebrew lazily before macOS Git installs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=macos
install_homebrew() { echo "install_homebrew"; }
run_quiet_step() { echo "run_quiet_step:$*"; return 0; }
install_git
`);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(
/install_homebrew\s+run_quiet_step:Installing Git brew install git/,
);
});
it("promotes a supported Linux Node binary over stale PATH entries", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-promote-"));
const staleBin = join(tmp, "usr-local-bin");
const supportedBin = join(tmp, "usr-bin");
mkdirSync(staleBin, { recursive: true });
mkdirSync(supportedBin, { recursive: true });
const staleNode = join(staleBin, "node");
const supportedNode = join(supportedBin, "node");
writeFileSync(staleNode, "#!/bin/sh\necho v20.20.0\n");
writeFileSync(supportedNode, "#!/bin/sh\necho v22.22.3\n");
chmodSync(staleNode, 0o755);
chmodSync(supportedNode, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`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",
"promote_status=$?",
"ensure_default_node_active_shell",
"active_status=$?",
'printf "promote=%s\\nactive=%s\\npath=%s\\nversion=%s\\n" "$promote_status" "$active_status" "$(command -v node)" "$(node -v)"',
"exit $active_status",
].join("\n"),
{
PATH: `${staleBin}:${supportedBin}:/usr/bin:/bin`,
SHELL: "/bin/bash",
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const output = result?.stdout ?? "";
expect(output).toContain("promote=0");
expect(output).toContain("active=0");
expect(output).toContain(`path=${supportedNode}`);
expect(output).toContain("version=v22.22.3");
});
it("mirrors the canonical release-label contract for existing Node runtimes", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
engines?: { node?: string };
};
expect(pkg.engines?.node).toBe(">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0");
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-floor-"));
const bin = join(tmp, "bin");
mkdirSync(bin, { recursive: true });
const nodePath = join(bin, "node");
writeFileSync(
nodePath,
["#!/bin/sh", 'printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"', ""].join("\n"),
);
chmodSync(nodePath, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"set +e",
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"export PATH",
"unset -f node 2>/dev/null || true",
"unalias node 2>/dev/null || true",
'node() { printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"; }',
...NODE_RELEASE_VERSION_CASES.flatMap((version, index) => [
`FAKE_NODE_VERSION=${JSON.stringify(version)}`,
"export FAKE_NODE_VERSION",
"node_is_supported",
`printf '${index}=%s\\n' "$?"`,
]),
"exit 0",
].join("\n"),
{
PATH: `${bin}:/usr/bin:/bin`,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
for (const [index, version] of NODE_RELEASE_VERSION_CASES.entries()) {
const expectedStatus = isSupportedOpenClawNodeVersion(version) ? 0 : 1;
expect(result?.stdout, version).toContain(`${index}=${expectedStatus}`);
}
});
it("rejects a supported Node version when its linked SQLite is unsafe", () => {
const result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"set +e",
"node() {",
' if [[ "${1:-}" == "-v" ]]; then printf "v24.17.0\\n"; return 0; fi',
' if [[ "${1:-}" == "-e" ]]; then return 1; fi',
" return 1",
"}",
"node_is_supported",
'printf "status=%s\\n" "$?"',
"exit 0",
].join("\n"),
{ TERM: "dumb" },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
});
it("persists a supported Linux Node path before noninteractive shell guards", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-linux-node-path-"));
const home = join(tmp, "home");
const oldBin = join(tmp, "old/bin");
const installedBin = join(tmp, "usr/bin");
mkdirSync(home, { recursive: true });
mkdirSync(oldBin, { recursive: true });
mkdirSync(installedBin, { recursive: true });
const oldNode = join(oldBin, "node");
const installedNode = join(installedBin, "node");
writeFileSync(
join(home, ".bashrc"),
[
"case $- in",
" *i*) ;;",
" *) return ;;",
"esac",
`export PATH="${installedBin}:$PATH"`,
"",
].join("\n"),
);
writeFileSync(
oldNode,
[
"#!/usr/bin/env bash",
'if [[ "${1:-}" == "-p" ]]; then echo "20 20"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v20.20.0"; exit 0; fi',
"",
].join("\n"),
);
writeFileSync(
installedNode,
[
"#!/usr/bin/env bash",
'if [[ "${1:-}" == "-p" ]]; then echo "24 15"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v24.15.0"; exit 0; fi',
"",
].join("\n"),
);
chmodSync(oldNode, 0o755);
chmodSync(installedNode, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
SHELL=/bin/bash
OS=linux
HOME=${JSON.stringify(home)}
PATH=${JSON.stringify(`${oldBin}:${installedBin}:/usr/bin:/bin`)}
ui_info() { :; }
activate_supported_node_on_path
printf 'first=%s\\n' "$(sed -n '1p' "$HOME/.bashrc")"
HOME=${JSON.stringify(home)} PATH=${JSON.stringify(`${oldBin}:${installedBin}:/usr/bin:/bin`)} bash -c 'source_rc() { . "$HOME/.bashrc"; }; source_rc; printf "node=%s\\n" "$(command -v node)"'
`);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain(`first=export PATH="${installedBin}:$PATH"`);
expect(result?.stdout).toContain(`node=${installedNode}`);
});
it("warns before redirecting an unwritable npm prefix", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-prefix-"));
const home = join(tmp, "home");
const events = join(tmp, "events.log");
mkdirSync(home, { recursive: true });
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
SHELL=/bin/bash
OS=linux
HOME=${JSON.stringify(home)}
prefix=${JSON.stringify(join(tmp, "root-owned-prefix"))}
events=${JSON.stringify(events)}
npm() {
if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then
printf '%s\\n' "$prefix"
return 0
fi
if [[ "$1" == "config" && "$2" == "set" && "$3" == "prefix" ]]; then
printf 'npm-set:%s\\n' "$4" >> "$events"
return 0
fi
return 1
}
ui_info() { printf 'info:%s\\n' "$*" >> "$events"; }
ui_warn() { printf 'warn:%s\\n' "$*" >> "$events"; }
ui_success() { printf 'success:%s\\n' "$*" >> "$events"; }
fix_npm_permissions
cat "$events"
`);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const lines = (result?.stdout ?? "").trim().split("\n");
const warningIndex = lines.findIndex((line) =>
line.includes("The installer will switch npm's user prefix"),
);
const npmSetIndex = lines.findIndex((line) => line.startsWith("npm-set:"));
const noSudoWarningIndex = lines.findIndex((line) => line.includes("Avoid sudo npm i -g"));
expect(warningIndex).toBeGreaterThanOrEqual(0);
expect(npmSetIndex).toBeGreaterThan(warningIndex);
expect(noSudoWarningIndex).toBeGreaterThan(npmSetIndex);
expect(result?.stdout).toContain("npm global prefix is not writable");
expect(result?.stdout).toContain("npm normally writes that setting to ~/.npmrc");
expect(result?.stdout).toContain("npm i -g openclaw@latest");
expect(result?.stdout).toContain("using this user prefix");
expect(result?.stdout).not.toContain("has been saved");
});
it("persists npm prefix PATH before noninteractive shell guards", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-prefix-shell-"));
const home = join(tmp, "home");
mkdirSync(home, { recursive: true });
writeFileSync(
join(home, ".bashrc"),
[
"case $- in",
" *i*) ;;",
" *) return ;;",
"esac",
'export PATH="$HOME/.npm-global/bin:$PATH"',
"",
].join("\n"),
);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
SHELL=/bin/bash
OS=linux
HOME=${JSON.stringify(home)}
PATH=/usr/bin:/bin
prefix=${JSON.stringify(join(tmp, "root-owned-prefix"))}
npm() {
if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then
printf '%s\\n' "$prefix"
return 0
fi
if [[ "$1" == "config" && "$2" == "set" && "$3" == "prefix" ]]; then
return 0
fi
return 1
}
ui_info() { :; }
ui_warn() { :; }
ui_success() { :; }
fix_npm_permissions
printf 'first=%s\\n' "$(sed -n '1p' "$HOME/.bashrc")"
HOME=${JSON.stringify(home)} PATH=/usr/bin:/bin bash -c 'source_rc() { . "$HOME/.bashrc"; }; source_rc; printf "path=%s\\n" "\${PATH%%:*}"'
`);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain('first=export PATH="$HOME/.npm-global/bin:$PATH"');
expect(result?.stdout).toContain(`path=${home}/.npm-global/bin`);
});
it("persists a fresh Git install to the default Bash startup contracts", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-git-shell-path-"));
const home = join(tmp, "home");
const bin = join(home, ".local", "bin");
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, "openclaw"), "#!/bin/sh\nexit 0\n");
chmodSync(join(bin, "openclaw"), 0o755);
try {
const persist = runInstallShell(
`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path; ensure_user_local_bin_on_path`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" },
);
const interactive = spawnSync(
"bash",
["-ic", "printf 'openclaw-path=%s\\n' \"$(command -v openclaw)\""],
{
encoding: "utf8",
env: { HOME: home, PATH: "/usr/bin:/bin", BASH_ENV: "", ENV: "" },
},
);
const login = spawnSync(
"bash",
["--noprofile", "--norc", "-c", '. "$HOME/.profile"; command -v openclaw'],
{
encoding: "utf8",
env: { HOME: home, PATH: "/usr/bin:/bin", BASH_ENV: "", ENV: "" },
},
);
expect(persist.status).toBe(0);
expect(interactive.status).toBe(0);
expect(interactive.stdout.match(/^openclaw-path=.*$/gm)).toEqual([
`openclaw-path=${join(bin, "openclaw")}`,
]);
expect(login.status).toBe(0);
expect(login.stdout.trim()).toBe(join(bin, "openclaw"));
for (const rc of [".bashrc", ".profile"]) {
expect(readFileSync(join(home, rc), "utf8")).toBe('export PATH="$HOME/.local/bin:$PATH"\n');
}
expect(existsSync(join(home, ".zshrc"))).toBe(false);
expect(existsSync(join(home, ".zprofile"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("persists to zsh contracts without creating unrelated Bash files", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-zsh-shell-path-"));
const home = join(tmp, "home");
const bin = join(home, ".local", "bin");
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, "openclaw"), "#!/bin/sh\nexit 0\n");
chmodSync(join(bin, "openclaw"), 0o755);
try {
const persist = runInstallShell(
`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path; ensure_user_local_bin_on_path`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/zsh" },
);
const zsh = spawnSync("zsh", ["--version"], { encoding: "utf8" });
expect(persist.status).toBe(0);
for (const rc of [".zshrc", ".zprofile"]) {
expect(readFileSync(join(home, rc), "utf8")).toBe('export PATH="$HOME/.local/bin:$PATH"\n');
}
expect(existsSync(join(home, ".bashrc"))).toBe(false);
expect(existsSync(join(home, ".profile"))).toBe(false);
if (zsh.status === 0) {
for (const args of [
["-ic", "command -v openclaw"],
["-lic", "command -v openclaw"],
]) {
const fresh = spawnSync("zsh", args, {
encoding: "utf8",
env: { HOME: home, PATH: "/usr/bin:/bin", ZDOTDIR: home },
});
expect(fresh.status).toBe(0);
expect(fresh.stdout.trim()).toBe(join(bin, "openclaw"));
}
}
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("updates existing startup files for dual-shell users", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-dual-shell-path-"));
const home = join(tmp, "home");
const collisionTarget = join(tmp, "collision-target");
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".bash_profile"), "# bash login\n");
writeFileSync(join(home, ".zshrc"), "# zsh interactive\n");
chmodSync(join(home, ".bash_profile"), 0o600);
chmodSync(join(home, ".zshrc"), 0o600);
writeFileSync(collisionTarget, "do not replace\n");
symlinkSync(collisionTarget, join(home, ".bash_profile.openclaw-tmp"));
try {
const result = runInstallShell(
`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path; ensure_user_local_bin_on_path`,
{
HOME: home,
PATH: "/usr/bin:/bin",
SHELL: "/bin/bash",
},
);
expect(result.status).toBe(0);
for (const rc of [".bashrc", ".bash_profile", ".zshrc"]) {
const path = join(home, rc);
expect(readFileSync(path, "utf8").match(/^export PATH=/gm)).toHaveLength(1);
if (rc !== ".bashrc") {
expect(statSync(path).mode & 0o777).toBe(0o600);
}
}
expect(readFileSync(collisionTarget, "utf8")).toBe("do not replace\n");
expect(readdirSync(home).filter((name) => name.includes(".openclaw-tmp."))).toEqual([]);
expect(existsSync(join(home, ".profile"))).toBe(false);
expect(existsSync(join(home, ".zprofile"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("uses only the first active Bash login profile", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-bash-precedence-"));
const home = join(tmp, "home");
mkdirSync(home, { recursive: true });
for (const rc of [".bash_profile", ".bash_login", ".profile"]) {
writeFileSync(join(home, rc), `# ${rc}\n`);
}
try {
const result = runInstallShell(`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path`, {
HOME: home,
PATH: "/usr/bin:/bin",
SHELL: "/bin/bash",
});
expect(result.status).toBe(0);
expect(readFileSync(join(home, ".bash_profile"), "utf8")).toContain(".local/bin");
expect(readFileSync(join(home, ".bash_login"), "utf8")).toBe("# .bash_login\n");
expect(readFileSync(join(home, ".profile"), "utf8")).toBe("# .profile\n");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("skips a dangling Bash login profile in favor of the readable fallback", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-bash-dangling-profile-"));
const home = join(tmp, "home");
mkdirSync(home, { recursive: true });
symlinkSync("missing-profile", join(home, ".bash_profile"));
writeFileSync(join(home, ".bash_login"), "# readable fallback\n");
try {
const result = runInstallShell(`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path`, {
HOME: home,
PATH: "/usr/bin:/bin",
SHELL: "/bin/bash",
});
expect(result.status).toBe(0);
expect(lstatSync(join(home, ".bash_profile")).isSymbolicLink()).toBe(true);
expect(existsSync(join(home, "missing-profile"))).toBe(false);
expect(readFileSync(join(home, ".bash_login"), "utf8")).toContain(".local/bin");
expect(existsSync(join(home, ".profile"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it.runIf(
process.getuid?.() !== 0 ||
spawnSync("setpriv", ["--version"], { encoding: "utf8" }).status === 0,
)("skips an unreadable Bash login profile in favor of the readable fallback", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-bash-unreadable-profile-"));
const home = join(tmp, "home");
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".bash_profile"), "# unreadable\n");
writeFileSync(join(home, ".bash_login"), "# readable fallback\n");
chmodSync(join(home, ".bash_profile"), 0o000);
try {
const script = `source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path`;
let result: ReturnType<typeof spawnSync>;
if (process.getuid?.() === 0) {
chmodSync(tmp, 0o755);
chownSync(home, 65534, 65534);
chownSync(join(home, ".bash_profile"), 65534, 65534);
chownSync(join(home, ".bash_login"), 65534, 65534);
result = spawnSync(
"setpriv",
["--reuid=65534", "--regid=65534", "--clear-groups", "bash", "-c", script],
{
encoding: "utf8",
env: {
...process.env,
HOME: home,
PATH: "/usr/bin:/bin",
SHELL: "/bin/bash",
BASH_ENV: "",
ENV: "",
OPENCLAW_INSTALL_SH_NO_RUN: "1",
},
},
);
} else {
result = runInstallShell(script, { HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" });
}
expect(result.status).toBe(0);
chmodSync(join(home, ".bash_profile"), 0o600);
expect(readFileSync(join(home, ".bash_profile"), "utf8")).toBe("# unreadable\n");
expect(readFileSync(join(home, ".bash_login"), "utf8")).toContain(".local/bin");
expect(existsSync(join(home, ".profile"))).toBe(false);
} finally {
chmodSync(join(home, ".bash_profile"), 0o600);
rmSync(tmp, { force: true, recursive: true });
}
});
it("updates a contained profile symlink target without replacing the link", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-profile-link-"));
const home = join(tmp, "home");
const managed = join(home, ".config", "shell", "profile");
mkdirSync(join(home, ".config", "shell"), { recursive: true });
writeFileSync(managed, "# managed profile\n");
chmodSync(managed, 0o600);
symlinkSync(".config/shell/profile", join(home, ".profile"));
try {
const result = runInstallShell(
`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path; ensure_user_local_bin_on_path`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" },
);
expect(result.status).toBe(0);
expect(lstatSync(join(home, ".profile")).isSymbolicLink()).toBe(true);
expect(readFileSync(managed, "utf8").match(/^export PATH=/gm)).toHaveLength(1);
expect(statSync(managed).mode & 0o777).toBe(0o600);
expect(
readdirSync(join(home, ".config", "shell")).filter((name) => name.includes("tmp")),
).toEqual([]);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it.runIf(
process.getuid?.() !== 0 ||
spawnSync("setpriv", ["--version"], { encoding: "utf8" }).status === 0,
)("updates a readable mode-0400 profile and preserves its mode", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-readonly-profile-"));
const home = join(tmp, "home");
const profile = join(home, ".profile");
mkdirSync(home, { recursive: true });
writeFileSync(profile, "# readonly profile\n");
chmodSync(profile, 0o400);
try {
const script = `source "${SCRIPT_PATH}"; persist_path_line_to_profile "$HOME/.profile" 'export PATH="$HOME/.local/bin:$PATH"'`;
let result: ReturnType<typeof spawnSync>;
if (process.getuid?.() === 0) {
chmodSync(tmp, 0o755);
chownSync(home, 65534, 65534);
chownSync(profile, 65534, 65534);
result = spawnSync(
"setpriv",
["--reuid=65534", "--regid=65534", "--clear-groups", "bash", "-c", script],
{
encoding: "utf8",
env: {
...process.env,
HOME: home,
PATH: "/usr/bin:/bin",
BASH_ENV: "",
ENV: "",
OPENCLAW_INSTALL_SH_NO_RUN: "1",
},
},
);
} else {
result = runInstallShell(script, { HOME: home, PATH: "/usr/bin:/bin" });
}
expect(result.status).toBe(0);
expect(readFileSync(profile, "utf8")).toBe(
'export PATH="$HOME/.local/bin:$PATH"\n# readonly profile\n',
);
expect(statSync(profile).mode & 0o777).toBe(0o400);
} finally {
chmodSync(profile, 0o600);
rmSync(tmp, { force: true, recursive: true });
}
});
it("refuses to create a Fish profile through a parent symlink outside HOME", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-fish-parent-link-"));
const home = join(tmp, "home");
const outside = join(tmp, "outside");
mkdirSync(home, { recursive: true });
mkdirSync(outside, { recursive: true });
symlinkSync(outside, join(home, ".config"));
try {
const result = runInstallShell(
`source "${SCRIPT_PATH}"; persist_shell_path_prepend "$HOME/.local/bin" '$HOME/.local/bin'`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/usr/bin/fish" },
);
expect(result.status).toBe(1);
expect(result.stdout + result.stderr).toContain(
"Refusing shell profile parent outside your home",
);
expect(existsSync(join(outside, "fish", "conf.d", "openclaw.fish"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("leaves a profile intact and reports failure when its metadata copy fails", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-profile-copy-failure-"));
const home = join(tmp, "home");
const profile = join(home, ".profile");
mkdirSync(home, { recursive: true });
writeFileSync(profile, "# original profile\n");
chmodSync(profile, 0o600);
const originalInode = statSync(profile).ino;
try {
const result = runInstallShell(
[
`source "${SCRIPT_PATH}"`,
"cp() { return 73; }",
`if ! persist_path_line_to_profile ${JSON.stringify(profile)} 'export PATH="$HOME/.local/bin:$PATH"'; then`,
" printf 'installer-reported-failure\\n'",
"else",
" exit 90",
"fi",
].join("\n"),
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" },
);
expect(result.status).toBe(0);
expect(result.stdout + result.stderr).toContain("Failed to copy shell profile");
expect(result.stdout).toContain("installer-reported-failure");
expect(readFileSync(profile, "utf8")).toBe("# original profile\n");
expect(statSync(profile).ino).toBe(originalInode);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("continues installation when an outside-home profile symlink is refused", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-outside-profile-link-"));
const home = join(tmp, "home");
const outsideProfile = join(tmp, "outside-profile");
mkdirSync(home, { recursive: true });
writeFileSync(outsideProfile, "# untouched\n");
symlinkSync(outsideProfile, join(home, ".profile"));
try {
const result = runInstallShell(
`set -e; source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path; printf 'installer-continued\\n'`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" },
);
expect(result.status).toBe(0);
expect(result.stdout + result.stderr).toContain("Refusing profile symlink outside your home");
expect(result.stdout).toContain("installer-continued");
expect(lstatSync(join(home, ".profile")).isSymbolicLink()).toBe(true);
expect(readFileSync(outsideProfile, "utf8")).toBe("# untouched\n");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("updates a Bash login profile after refusing the interactive profile", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-refused-bashrc-"));
const home = join(tmp, "home");
mkdirSync(join(home, ".bashrc"), { recursive: true });
writeFileSync(join(home, ".profile"), "# login profile\n");
try {
const result = runInstallShell(
`source "${SCRIPT_PATH}"; persist_shell_path_prepend "$HOME/.local/bin" '\$HOME/.local/bin'`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" },
);
expect(result.status).toBe(1);
expect(result.stdout + result.stderr).toContain("Refusing non-regular shell profile");
expect(readFileSync(join(home, ".profile"), "utf8")).toBe(
'export PATH="$HOME/.local/bin:$PATH"\n# login profile\n',
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it.each(["", "/bin/tcsh"])("does not mutate profiles for unknown SHELL=%s", (shell) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-unknown-shell-"));
const home = join(tmp, "home");
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".profile"), "# untouched\n");
try {
const result = runInstallShell(`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path`, {
HOME: home,
PATH: "/usr/bin:/bin",
SHELL: shell,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("PATH was not persisted");
expect(result.stdout).toContain("Fish: fish_add_path --");
expect(readFileSync(join(home, ".profile"), "utf8")).toBe("# untouched\n");
expect(existsSync(join(home, ".bashrc"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("writes Fish syntax to conf.d and loads it in a real Fish shell when available", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-fish-path-"));
const home = join(tmp, "home");
const bin = join(home, ".local", "bin");
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, "openclaw"), "#!/bin/sh\nexit 0\n");
chmodSync(join(bin, "openclaw"), 0o755);
try {
const persist = runInstallShell(
`source "${SCRIPT_PATH}"; ensure_user_local_bin_on_path; ensure_user_local_bin_on_path`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/usr/bin/fish" },
);
const fishRc = join(home, ".config", "fish", "conf.d", "openclaw.fish");
expect(persist.status).toBe(0);
expect(readFileSync(fishRc, "utf8")).toBe('fish_add_path -- "$HOME/.local/bin"\n');
expect(existsSync(join(home, ".bashrc"))).toBe(false);
const warning = runInstallShell(
`source "${SCRIPT_PATH}"; warn_shell_path_missing_dir "$HOME/.local/bin" "user-local bin dir"`,
{ HOME: home, PATH: "/usr/bin:/bin", SHELL: "/usr/bin/fish" },
);
expect(warning.status).toBe(0);
expect(warning.stdout).toContain(`PATH updated in ${fishRc}`);
expect(warning.stdout).not.toContain("PATH missing user-local bin dir");
const fishVersion = spawnSync("fish", ["--version"], { encoding: "utf8" });
if (fishVersion.status === 0) {
const fresh = spawnSync("fish", ["-lc", "command -v openclaw"], {
encoding: "utf8",
env: { HOME: home, PATH: "/usr/bin:/bin" },
});
expect(fresh.status).toBe(0);
expect(fresh.stdout.trim()).toBe(join(bin, "openclaw"));
}
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("uses a quoted absolute openclaw path in follow-up commands when npm bin is not on the original PATH", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-command-"));
const npmBin = join(tmp, "npm bin");
const staleBin = join(tmp, "stale-bin");
const visibleBin = join(tmp, "visible-bin");
mkdirSync(npmBin, { recursive: true });
mkdirSync(staleBin, { recursive: true });
mkdirSync(visibleBin, { recursive: true });
const openclawBin = join(npmBin, "openclaw");
const staleOpenclawBin = join(staleBin, "openclaw");
writeFileSync(openclawBin, "#!/bin/sh\nexit 0\n");
writeFileSync(staleOpenclawBin, "#!/bin/sh\nexit 0\n");
chmodSync(openclawBin, 0o755);
chmodSync(staleOpenclawBin, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
ORIGINAL_PATH=${JSON.stringify(`${visibleBin}:/usr/bin:/bin`)}
printf 'missing=%s\\n' "$(openclaw_command_for_user "${openclawBin}")"
ORIGINAL_PATH=${JSON.stringify(`${npmBin}:${visibleBin}:/usr/bin:/bin`)}
printf 'present=%s\\n' "$(openclaw_command_for_user "${openclawBin}")"
ORIGINAL_PATH=${JSON.stringify(`${staleBin}:${npmBin}:/usr/bin:/bin`)}
printf 'shadowed=%s\\n' "$(openclaw_command_for_user "${openclawBin}")"
`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain(`missing=${openclawBin.replace(/ /g, "\\ ")}`);
expect(result?.stdout).toContain("present=openclaw");
expect(result?.stdout).toContain(`shadowed=${openclawBin.replace(/ /g, "\\ ")}`);
});
it("prefers the binary owned by the completed install method over stale PATH entries", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-selected-bin-"));
const home = join(tmp, "home");
const npmBin = join(tmp, "npm-bin");
const staleBin = join(tmp, "stale-bin");
const gitBin = join(home, ".local", "bin");
mkdirSync(npmBin, { recursive: true });
mkdirSync(staleBin, { recursive: true });
mkdirSync(gitBin, { recursive: true });
for (const bin of [
join(npmBin, "openclaw"),
join(staleBin, "openclaw"),
join(gitBin, "openclaw"),
]) {
writeFileSync(bin, "#!/bin/sh\nexit 0\n");
chmodSync(bin, 0o755);
}
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
`
set -euo pipefail
source "${SCRIPT_PATH}"
INSTALL_METHOD=git
printf 'git=%s\\n' "$(resolve_installed_openclaw_bin)"
INSTALL_METHOD=npm
npm_global_bin_dir() { printf '%s\\n' "${npmBin}"; }
printf 'npm=%s\\n' "$(resolve_installed_openclaw_bin)"
`,
{
HOME: home,
PATH: `${staleBin}:${process.env.PATH ?? ""}`,
},
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain(`git=${join(gitBin, "openclaw")}`);
expect(result?.stdout).toContain(`npm=${join(npmBin, "openclaw")}`);
});
it("uses the selected binary in gateway recovery guidance", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-gateway-guidance-"));
const currentBin = join(tmp, "current bin");
const staleBin = join(tmp, "stale-bin");
mkdirSync(currentBin, { recursive: true });
mkdirSync(staleBin, { recursive: true });
const openclawBin = join(currentBin, "openclaw");
writeFileSync(openclawBin, "#!/bin/sh\nexit 0\n");
writeFileSync(join(staleBin, "openclaw"), "#!/bin/sh\nexit 0\n");
chmodSync(openclawBin, 0o755);
chmodSync(join(staleBin, "openclaw"), 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OPENCLAW_BIN=${JSON.stringify(openclawBin)}
ORIGINAL_PATH=${JSON.stringify(`${staleBin}:${currentBin}:/usr/bin:/bin`)}
VERIFY_INSTALL=1
is_gateway_daemon_loaded() { return 0; }
run_quiet_step() {
case "$1" in
"Restarting gateway service"|"Checking gateway service") return 1 ;;
*) return 0 ;;
esac
}
refresh_gateway_service_if_loaded
verify_installation true || true
`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const quotedBin = openclawBin.replace(/ /g, "\\ ");
expect(result?.status).toBe(0);
expect(result?.stdout).toContain(`Run: ${quotedBin} gateway restart`);
expect(result?.stdout).toContain(`Run: ${quotedBin} gateway status --deep`);
});
it("refreshes the shell command cache after loading a persisted PATH update", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
printf 'export PATH="$HOME/.local/bin:$PATH"\\n' > "$HOME/.bashrc"
ORIGINAL_PATH="/usr/bin:/bin"
warn_shell_path_missing_dir "$HOME/.local/bin" "user-local bin dir"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("For this shell, run: source ");
expect(result.stdout).toContain("; hash -r");
});
it("resolves requested git install versions to checkout refs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
npm() {
if [[ "$1" == "view" && "$2" == "openclaw" && "$3" == "dist-tags.beta" ]]; then
printf '2026.5.12-beta.3\\n'
return 0
fi
return 1
}
OPENCLAW_VERSION=v2026.5.12-beta.3
printf 'tag=%s\\n' "$(resolve_git_openclaw_ref)"
OPENCLAW_VERSION=2026.5.12-beta.3
printf 'semver=%s\\n' "$(resolve_git_openclaw_ref)"
OPENCLAW_VERSION=beta
printf 'beta=%s\\n' "$(resolve_git_openclaw_ref)"
OPENCLAW_VERSION=main
printf 'main=%s\\n' "$(resolve_git_openclaw_ref)"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("tag=v2026.5.12-beta.3");
expect(result.stdout).toContain("semver=v2026.5.12-beta.3");
expect(result.stdout).toContain("beta=v2026.5.12-beta.3");
expect(result.stdout).toContain("main=main");
});
it("fetches moving git refs without tags for git installs", () => {
expect(script).toContain('git -C "$repo_dir" fetch --no-tags origin main');
expect(script).toContain(
'git -C "$repo_dir" fetch --no-tags origin "refs/heads/${ref}:refs/remotes/origin/${ref}"',
);
expect(script).toContain('git -C "$repo_dir" pull --rebase --no-tags || true');
const branchCheckIndex = script.indexOf('ls-remote --exit-code --heads origin "$ref"');
const tagFetchIndex = script.indexOf("fetch --tags origin");
expect(branchCheckIndex).toBeGreaterThan(-1);
expect(tagFetchIndex).toBeGreaterThan(-1);
expect(branchCheckIndex).toBeLessThan(tagFetchIndex);
});
it("uses non-frozen lockfile installs only for moving git refs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
git() {
if [[ "$1" == "-C" && "$3" == "ls-remote" && "\${7:-}" == "feature" ]]; then
return 0
fi
return 1
}
printf 'main=%s\\n' "$(git_install_lockfile_flag /repo main)"
printf 'branch=%s\\n' "$(git_install_lockfile_flag /repo feature)"
printf 'tag=%s\\n' "$(git_install_lockfile_flag /repo v2026.5.12)"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("main=--no-frozen-lockfile");
expect(result.stdout).toContain("branch=--no-frozen-lockfile");
expect(result.stdout).toContain("tag=--frozen-lockfile");
expect(script).toContain(
'CI="${CI:-true}" run_quiet_step "Installing dependencies" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"',
);
});
it("aligns pnpm to the checked-out repo packageManager before installing", () => {
expect(script).toContain("activate_repo_pnpm_version()");
expect(script).toContain('corepack prepare "pnpm@${version}" --activate');
expect(script).toContain('activate_repo_pnpm_version "$repo_dir"');
});
it("uses the repo Corepack pnpm when a global pnpm version is already present", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-pnpm-version-"));
const bin = join(tmp, "bin");
const outer = join(tmp, "outer");
const repo = join(tmp, "repo");
mkdirSync(bin, { recursive: true });
mkdirSync(outer, { recursive: true });
mkdirSync(repo, { recursive: true });
writeFileSync(join(outer, "package.json"), '{\n "packageManager": "yarn@4.5.0"\n}\n');
writeFileSync(
join(repo, "package.json"),
'{\n "packageManager": "pnpm@11.2.2+sha512.test"\n}\n',
);
writeFileSync(
join(bin, "pnpm"),
["#!/bin/bash", '[[ "${1:-}" == "--version" ]] && echo "11.8.0"', ""].join("\n"),
);
writeFileSync(
join(bin, "corepack"),
[
"#!/bin/bash",
'if [[ "${1:-}" == "prepare" ]]; then exit 0; fi',
'if [[ "${1:-}" == "pnpm" && "${2:-}" == "--version" ]]; then',
' if grep -q "pnpm@11.2.2" package.json 2>/dev/null; then echo "11.2.2"; else exit 1; fi',
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
);
chmodSync(join(bin, "pnpm"), 0o755);
chmodSync(join(bin, "corepack"), 0o755);
try {
const result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`cd ${JSON.stringify(outer)}`,
`activate_repo_pnpm_version ${JSON.stringify(repo)}`,
'printf "cmd=%s\\n" "${PNPM_CMD[*]}"',
`printf "run=%s\\n" "$(run_pnpm -C ${JSON.stringify(repo)} --version)"`,
].join("\n"),
{ PATH: `${bin}:${process.env.PATH ?? ""}` },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("cmd=corepack pnpm");
expect(result.stdout).toContain("run=11.2.2");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not treat /dev/tty permissions as a controlling terminal", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
if has_controlling_tty; then echo "has_tty=1"; else echo "has_tty=0"; fi
if is_promptable; then echo "promptable=1"; else echo "promptable=0"; fi
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("has_tty=0");
expect(result.stdout).toContain("promptable=0");
});
});
describe("install.sh macOS Homebrew Node behavior", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
it("stops when Homebrew node installation fails", () => {
expect(script).toContain(
'if ! run_quiet_step "Installing ${NODE_BREW_FORMULA}" brew install "${NODE_BREW_FORMULA}"; then',
);
const failedInstallIndex = script.indexOf(
'if ! run_quiet_step "Installing ${NODE_BREW_FORMULA}" brew install "${NODE_BREW_FORMULA}"; then',
);
const brewLinkIndex = script.indexOf('brew link "${NODE_BREW_FORMULA}" --overwrite --force');
expect(failedInstallIndex).toBeGreaterThanOrEqual(0);
expect(brewLinkIndex).toBeGreaterThan(failedInstallIndex);
});
it("aborts before brew link when Homebrew node installation fails at runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=macos
run_quiet_step() { echo "run_quiet_step:$*"; return 1; }
brew() { echo "brew:$*"; return 0; }
ensure_macos_default_node_active() { echo "ensure-called"; return 0; }
if install_node; then
echo "install_node returned success"
else
echo "install_node returned failure"
fi
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"Re-run with --verbose or run 'brew install node' directly, then rerun the installer.",
);
expect(result.stdout).not.toContain("brew:link");
expect(result.stdout).not.toContain("ensure-called");
});
it("separates missing Homebrew node from PATH shadowing", () => {
const missingNodeGuardIndex = script.indexOf(
'if [[ -z "$brew_node_prefix" || ! -x "${brew_node_prefix}/bin/node" ]]; then',
);
const pathAdviceIndex = script.indexOf("Add this to your shell profile and restart shell:");
expect(missingNodeGuardIndex).toBeGreaterThanOrEqual(0);
expect(script).toContain('ui_error "Homebrew ${NODE_BREW_FORMULA} is not installed on disk"');
expect(script).toContain('echo " export PATH=\\"${brew_node_prefix}/bin:\\$PATH\\""');
expect(pathAdviceIndex).toBeGreaterThan(missingNodeGuardIndex);
});
it("does not print PATH advice when Homebrew node is missing at runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=macos
missing_prefix="$(mktemp -d)/node"
brew() {
if [[ "$1" == "--prefix" ]]; then
echo "$missing_prefix"
return 0
fi
return 0
}
node_is_supported() { return 1; }
if ensure_macos_default_node_active; then
echo "ensure returned success"
else
echo "ensure returned failure"
fi
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("Homebrew node is not installed on disk");
expect(result.stdout).toContain("ensure returned failure");
expect(result.stdout).not.toContain("Node.js v26 was installed");
expect(result.stdout).not.toContain("Add this to your shell profile");
});
it("falls back when gum reports raw-mode ioctl failures", () => {
expect(script).toContain("setrawmode|inappropriate ioctl");
expect(script).toContain(
'"$GUM" spin --spinner dot --title "$title" -- "$@" < /dev/null >"$gum_out" 2>"$gum_err" || gum_status=$?',
);
expect(script).toContain(
'"$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err" || gum_status=$?',
);
expect(script).toContain(
'if is_gum_raw_mode_failure "$gum_out" || is_gum_raw_mode_failure "$gum_err"; then',
);
expect(script).toContain(
'ui_warn "Spinner unavailable in this terminal; continuing without spinner"',
);
expect(script).toContain(
'if needs_stdin_isolation; then\n "$@" < /dev/null\n else\n "$@"\n fi\n return $?',
);
});
it("reruns spinner-wrapped commands when gum reports ioctl failure", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-install-sh-gum-"));
try {
const gumPath = join(dir, "gum");
const commandPath = join(dir, "command");
const markerPath = join(dir, "marker");
writeFileSync(
gumPath,
"#!/usr/bin/env bash\nprintf 'inappropriate ioctl for device\\n'\nexit 0\n",
{ mode: 0o755 },
);
writeFileSync(commandPath, `#!/usr/bin/env bash\nprintf 'ran' >"${markerPath}"\n`, {
mode: 0o755,
});
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
gum_is_tty() { return 0; }
GUM="${gumPath}"
run_with_spinner "Installing node" "${commandPath}"
cat "${markerPath}"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
"Spinner unavailable in this terminal; continuing without spinner",
);
expect(result.stdout).toContain("ran");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("gum spin preserves terminal stdin for direct interactive installs", () => {
// When needs_stdin_isolation returns false (direct interactive run),
// gum spin should NOT redirect stdin from /dev/null so that wrapped
// commands like Homebrew can still prompt the user via stdin.
const dir = mkdtempSync(join(tmpdir(), "openclaw-install-sh-gum-stdin-"));
try {
const gumPath = join(dir, "gum");
const commandPath = join(dir, "command");
const stdinLog = join(dir, "stdin-source");
// Gum stub: skip args up to and including "--", then run the rest
writeFileSync(
gumPath,
'#!/usr/bin/env bash\nwhile [[ "$#" -gt 0 && "$1" != "--" ]]; do shift; done\nshift\n"$@"\n',
{ mode: 0o755 },
);
// Command: detects whether stdin is literally /dev/null by comparing
// device:inode of fd 0 against /dev/null (reliable across macOS/Linux)
writeFileSync(
commandPath,
`#!/usr/bin/env bash
stdin_dev=$(stat -f '%d:%i' /dev/fd/0 2>/dev/null || stat -c '%d:%i' /dev/fd/0 2>/dev/null)
null_dev=$(stat -f '%d:%i' /dev/null 2>/dev/null || stat -c '%d:%i' /dev/null 2>/dev/null)
if [ "$stdin_dev" = "$null_dev" ]; then echo "devnull" > "${stdinLog}"; else echo "other" > "${stdinLog}"; fi
exit 0
`,
{ mode: 0o755 },
);
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
# Override needs_stdin_isolation to return false (direct interactive)
needs_stdin_isolation() { return 1; }
gum_is_tty() { return 0; }
GUM="${gumPath}"
run_with_spinner "Installing node" "${commandPath}"
`);
// The gum spin command should NOT have redirected stdin from /dev/null
expect(result.status).toBe(0);
// Assert the child command's stdin was NOT /dev/null
const observed = readFileSync(stdinLog, "utf8").trim();
expect(observed).toBe("other");
expect(script).toContain("needs_stdin_isolation; then");
expect(script).toContain(
'"$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err" || gum_status=$?',
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("gum spin redirects stdin from /dev/null for piped installs", () => {
expect(script).toContain("needs_stdin_isolation; then");
expect(script).toContain(
'"$GUM" spin --spinner dot --title "$title" -- "$@" < /dev/null >"$gum_out" 2>"$gum_err" || gum_status=$?',
);
});
});
describe("install.sh duplicate OpenClaw install detection", () => {
it("warns with concrete package paths and versions for duplicate npm roots", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
mkdir -p "$root/brew/openclaw" "$root/fnm/openclaw"
printf '{"version":"2026.3.7"}\\n' > "$root/brew/openclaw/package.json"
printf '{"version":"2026.3.1"}\\n' > "$root/fnm/openclaw/package.json"
collect_openclaw_npm_root_candidates() { printf '%s\\n' "$root/brew" "$root/fnm"; }
OPENCLAW_BIN="$root/fnm/.bin/openclaw"
ui_warn() { echo "WARN: $*"; }
warn_duplicate_openclaw_global_installs
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("Multiple OpenClaw global installs detected");
expect(result.stdout).toContain("2026.3.7");
expect(result.stdout).toContain("2026.3.1");
expect(result.stdout).toContain("/brew/openclaw");
expect(result.stdout).toContain("/fnm/openclaw");
expect(result.stdout).toContain("Active openclaw:");
expect(result.stdout).toContain("npm uninstall -g openclaw");
});
it("stays quiet when only one OpenClaw npm root exists", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
mkdir -p "$root/only/openclaw"
printf '{"version":"2026.3.7"}\\n' > "$root/only/openclaw/package.json"
collect_openclaw_npm_root_candidates() { printf '%s\\n' "$root/only"; }
ui_warn() { echo "WARN: $*"; }
warn_duplicate_openclaw_global_installs
`);
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Multiple OpenClaw global installs detected");
});
it("needs_stdin_isolation returns true when stdin is piped", () => {
const result = spawnSync(
"bash",
[
"-c",
`source "${SCRIPT_PATH}" && needs_stdin_isolation && echo "ISOLATED" || echo "INTERACTIVE"`,
],
{
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
HOME: tmpdir(),
OPENCLAW_INSTALL_SH_NO_RUN: "1",
BASH_ENV: "",
ENV: "",
},
input: "",
},
);
expect(result.stdout.trim()).toBe("ISOLATED");
});
it("needs_stdin_isolation returns true when NO_PROMPT is set", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NO_PROMPT=1
needs_stdin_isolation && echo "ISOLATED" || echo "INTERACTIVE"
`);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("ISOLATED");
});
it("routes piped interactive subprocesses through the controlling TTY", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NO_PROMPT=0
needs_stdin_isolation() { return 0; }
has_controlling_tty() { return 0; }
resolve_subprocess_stdin_path 1
`);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("/dev/tty");
});
it("keeps piped subprocesses nonblocking when prompt output is redirected", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NO_PROMPT=0
needs_stdin_isolation() { return 0; }
has_controlling_tty() { return 0; }
resolve_subprocess_stdin_path 0
`);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("/dev/null");
});
it("captures visible prompt output before resolving subprocess stdin", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
marker="$(mktemp)"
trap 'rm -f "$marker"' EXIT
has_visible_prompt_output() { return 0; }
resolve_subprocess_stdin_path() {
echo "visible=$1" > "$marker"
return 1
}
run_with_safe_stdin true
cat "$marker"
`);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("visible=1");
});
it("routes non-promptable subprocesses through /dev/null", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NO_PROMPT=1
has_controlling_tty() { return 0; }
resolve_subprocess_stdin_path
`);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("/dev/null");
});
it("run_quiet_step redirects stdin to /dev/null in piped context", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-stdin-test-"));
const marker = join(dir, "stdin-state");
try {
const result = spawnSync(
"bash",
[
"-c",
`source "${SCRIPT_PATH}" && GUM="" && run_quiet_step "test-step" bash -c 'if read -t 1 line 2>/dev/null && [ -n "$line" ]; then echo "LEAKED:$line" > ${JSON.stringify(marker)}; else echo ISOLATED > ${JSON.stringify(marker)}; fi'`,
],
{
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
HOME: tmpdir(),
NO_PROMPT: "1",
OPENCLAW_INSTALL_SH_NO_RUN: "1",
BASH_ENV: "",
ENV: "",
},
input: "SENTINEL_DATA_SHOULD_NOT_LEAK\n",
},
);
expect(result.status).toBe(0);
const stdinState = readFileSync(marker, "utf8").trim();
expect(stdinState).toBe("ISOLATED");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
it("pipe data leaks to child when stdin is not isolated (counterproof)", () => {
// This test proves the fix is necessary: without /dev/null redirect,
// pipe data from the installer invocation reaches the child process.
// If this test ever fails, the isolation in run_quiet_step is no longer
// the only barrier protecting child processes from pipe consumption.
const dir = mkdtempSync(join(tmpdir(), "openclaw-stdin-leak-"));
const marker = join(dir, "stdin-state");
try {
const result = spawnSync(
"bash",
[
"-c",
// Bypass run_quiet_step: call the child directly with inherited stdin
`source "${SCRIPT_PATH}" && bash -c 'output=$(cat); if [ -n "$output" ]; then echo "LEAKED" > ${JSON.stringify(marker)}; else echo "EMPTY" > ${JSON.stringify(marker)}; fi'`,
],
{
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
HOME: tmpdir(),
OPENCLAW_INSTALL_SH_NO_RUN: "1",
BASH_ENV: "",
ENV: "",
},
input: "SENTINEL_DATA_SHOULD_LEAK\n",
},
);
expect(result.status).toBe(0);
const stdinState = readFileSync(marker, "utf8").trim();
// Without /dev/null redirect, cat reads the sentinel from the pipe.
expect(stdinState).toBe("LEAKED");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
it("run_quiet_step blocks cat from reading pipe data", () => {
// Stronger version of the isolation test: uses cat to consume all of
// stdin and verifies it reads nothing (empty output from /dev/null).
const dir = mkdtempSync(join(tmpdir(), "openclaw-stdin-cat-"));
const marker = join(dir, "stdin-state");
try {
const result = spawnSync(
"bash",
[
"-c",
`source "${SCRIPT_PATH}" && GUM="" && run_quiet_step "test-step" bash -c 'output=$(cat); if [ -n "$output" ]; then echo "LEAKED" > ${JSON.stringify(marker)}; else echo "ISOLATED" > ${JSON.stringify(marker)}; fi'`,
],
{
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
HOME: tmpdir(),
NO_PROMPT: "1",
OPENCLAW_INSTALL_SH_NO_RUN: "1",
BASH_ENV: "",
ENV: "",
},
input: "SENTINEL_DATA_SHOULD_NOT_LEAK\n",
},
);
expect(result.status).toBe(0);
const stdinState = readFileSync(marker, "utf8").trim();
expect(stdinState).toBe("ISOLATED");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
describe("install.sh doctor cancellation and dashboard guard", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
it("preserves dashboard stdin for direct interactive installs", () => {
expect(script).toContain('run_with_safe_stdin "$claw" dashboard || true');
});
it("preserves plugin update stdin for direct interactive upgrades", () => {
expect(script).toContain(
'OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true',
);
});
it("guards every run_doctor caller against failure", () => {
// A failed or cancelled doctor must not launch the dashboard.
expect(script).toContain("run_doctor || return $?");
// Ensure there is no bare "run_doctor" call followed by
// "should_open_dashboard=true" without an if-guard
const bareDoctor = /^\s+run_doctor\s*$/m;
const lines = script.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line !== undefined && bareDoctor.test(line)) {
// A bare run_doctor is only acceptable inside the run_doctor
// function definition itself, not at a call site
const context = lines.slice(Math.max(0, i - 3), i + 3).join("\n");
if (!context.includes("run_doctor()")) {
throw new Error(
`Unguarded run_doctor call at line ${i + 1}. ` +
`All run_doctor callers must check the return value.`,
);
}
}
}
});
it("clears dashboard flag when doctor fails during upgrade", () => {
// The upgrade interactive doctor path must clear should_open_dashboard
// when doctor_exit is non-zero.
expect(script).toContain("should_open_dashboard=false");
expect(script).toContain("if (( doctor_exit != 0 )); then");
});
it("propagates signal exit codes through run_quiet_step", () => {
// run_quiet_step preserves signal exit codes (130=SIGINT, 143=SIGTERM)
// so run_doctor can detect user cancellation.
expect(script).toContain("if (( cmd_exit > 128 )); then");
expect(script).toContain('return "$cmd_exit"');
});
it("aborts on SIGINT (exit 130) from doctor", () => {
// Both the run_doctor function and the interactive doctor path
// must call abort_install_int on exit code 130.
expect(script).toContain("if (( doctor_exit == 130 )); then");
expect(script).toContain("abort_install_int");
});
});