// 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 }); } } function linkNodeExecutable(bin: string) { symlinkSync(process.execPath, join(bin, "node")); } 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"), "unexpected response\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 'unexpected response\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(run("npm 12.0.0 warning", "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" < "$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 | 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 | 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 | 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 | 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.each([ { name: "fresh retained config rejects failed Doctor before success", configured: true, upgrade: false, verify: false, doctorExit: 9, verifyExit: 0, onboard: false, expectedStatus: 9, }, { name: "fresh retained config reports success only after Doctor", configured: true, upgrade: false, verify: false, doctorExit: 0, verifyExit: 0, onboard: false, expectedStatus: 0, }, { name: "fresh explicit verification rejects failure before success", configured: false, upgrade: false, verify: true, doctorExit: 0, verifyExit: 1, onboard: false, expectedStatus: 1, }, { name: "fresh explicit verification reports success only after verification", configured: false, upgrade: false, verify: true, doctorExit: 0, verifyExit: 0, onboard: false, expectedStatus: 0, }, { name: "upgrade implicit verification counts four stages before success", configured: true, upgrade: true, verify: false, doctorExit: 0, verifyExit: 0, onboard: false, expectedStatus: 0, }, { name: "upgrade rejects failed Doctor before success", configured: true, upgrade: true, verify: false, doctorExit: 9, verifyExit: 0, onboard: false, expectedStatus: 9, }, { name: "upgrade rejects failed verification before success", configured: true, upgrade: true, verify: false, doctorExit: 0, verifyExit: 1, onboard: false, expectedStatus: 1, }, { name: "plain fresh install reports success before skipping onboarding", configured: false, upgrade: false, verify: false, doctorExit: 0, verifyExit: 0, onboard: false, expectedStatus: 0, }, { name: "plain fresh install reports success before optional onboarding handoff", configured: false, upgrade: false, verify: false, doctorExit: 0, verifyExit: 0, onboard: true, expectedStatus: 0, }, { name: "fresh verification completes before success and optional onboarding handoff", configured: false, upgrade: false, verify: true, doctorExit: 0, verifyExit: 0, onboard: true, expectedStatus: 0, }, ])( "required installer lifecycle: $name", ({ configured, upgrade, verify, doctorExit, verifyExit, onboard, expectedStatus }) => { const result = runInstallShell( ` date() { printf '2026-08-20\\n'; } dirname() { printf 'scripts\\n'; } PATH=/__openclaw_installer_test_no_external_commands__ source "${SCRIPT_PATH}" cleanup_tmpfiles() { :; } INSTALL_METHOD=git GIT_DIR= NO_PROMPT=0 NO_ONBOARD="$SCENARIO_NO_ONBOARD" VERIFY_INSTALL="$SCENARIO_VERIFY" OS=linux forbidden_command() { printf 'forbidden external command: %s\\n' "$1" >&2 return 98 } launchctl() { forbidden_command launchctl; } systemctl() { forbidden_command systemctl; } schtasks() { forbidden_command schtasks; } sudo() { forbidden_command sudo; } curl() { forbidden_command curl; } wget() { forbidden_command wget; } brew() { forbidden_command brew; } git() { forbidden_command git; } node() { forbidden_command node; } openclaw() { forbidden_command openclaw; } run_quiet_step() { forbidden_command run_quiet_step; } run_with_safe_stdin() { forbidden_command run_with_safe_stdin; } install_homebrew() { forbidden_command install_homebrew; } install_node() { forbidden_command install_node; } install_git() { forbidden_command install_git; } 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() { [[ "$SCENARIO_UPGRADE" == 1 ]]; } 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() { printf 'event:installed\\n'; } resolve_installed_openclaw_bin() { printf '/nonexistent/mock-openclaw\\n'; } warn_duplicate_openclaw_global_installs() { :; } npm_global_bin_dir() { :; } warn_shell_path_missing_dir() { :; } has_openclaw_config() { [[ "$SCENARIO_CONFIGURED" == 1 ]]; } refresh_gateway_service_if_loaded() { printf 'event:service-refresh-mocked\\n'; } has_controlling_tty() { return 1; } is_gateway_daemon_loaded() { return 1; } is_promptable() { printf 'event:onboarding-handoff-probe\\n' return 1 } run_doctor() { printf 'event:doctor\\n' return "$SCENARIO_DOCTOR_EXIT" } resolve_openclaw_version() { printf '2026.8.20-test\\n'; } verify_installation() { [[ "$VERIFY_INSTALL" == 1 ]] || return 0 ui_stage "Verifying installation" printf 'event:verification\\n' return "$SCENARIO_VERIFY_EXIT" } maybe_open_dashboard() { printf 'event:dashboard-mocked\\n'; } show_footer_links() { printf 'event:footer\\n'; } ui_section() { printf 'event:stage:%s\\n' "$1"; } ui_info() { printf 'event:info:%s\\n' "$*"; } ui_celebrate() { printf 'event:success:%s\\n' "$*"; } configure_install_stage_total main `, { OPENCLAW_CONFIG_PATH: "", OPENCLAW_HOME: "", OPENCLAW_STATE_DIR: "", OPENCLAW_INSTALL_METHOD: "", OPENCLAW_VERIFY_INSTALL: "0", OPENCLAW_NO_ONBOARD: "0", OPENCLAW_NO_PROMPT: "0", SCENARIO_CONFIGURED: configured ? "1" : "0", SCENARIO_UPGRADE: upgrade ? "1" : "0", SCENARIO_VERIFY: verify ? "1" : "0", SCENARIO_DOCTOR_EXIT: String(doctorExit), SCENARIO_VERIFY_EXIT: String(verifyExit), SCENARIO_NO_ONBOARD: onboard || configured ? "0" : "1", TERM: "dumb", }, ); expect(result.status, result.stderr || result.stdout).toBe(expectedStatus); expect(result.stderr).not.toContain("forbidden external command"); const output = result.stdout; const successMatches = output.match(/OpenClaw installed successfully/g) ?? []; const doctorIndex = output.indexOf("event:doctor"); const verificationIndex = output.indexOf("event:verification"); const successIndex = output.indexOf("event:success:"); if (expectedStatus !== 0) { expect(successMatches).toHaveLength(0); expect(output).not.toContain("Upgrade complete"); return; } expect(successMatches).toHaveLength(1); if (configured) { expect(doctorIndex).toBeGreaterThan(-1); expect(doctorIndex).toBeLessThan(successIndex); } else { expect(doctorIndex).toBe(-1); } if (verify || upgrade) { expect(verificationIndex).toBeGreaterThan(-1); expect(verificationIndex).toBeLessThan(successIndex); expect(output).toContain("[4/4] Verifying installation"); expect(output).not.toContain("[4/3] Verifying installation"); } else { expect(verificationIndex).toBe(-1); expect(output).toContain("[3/3] Finalizing setup"); } if (upgrade) { const upgradeCompletionIndex = output.indexOf("event:info:Upgrade complete"); expect(upgradeCompletionIndex).toBeGreaterThan(successIndex); } else { expect(output).not.toContain("Upgrade complete"); } if (onboard) { const setupIndex = output.indexOf("event:info:Starting setup"); const handoffProbeIndex = output.indexOf("event:onboarding-handoff-probe"); expect(setupIndex).toBeGreaterThan(successIndex); expect(handoffProbeIndex).toBeGreaterThan(setupIndex); } else if (!configured) { expect(output.indexOf("event:info:Skipping onboard")).toBeGreaterThan(successIndex); } }, ); it("required installer lifecycle: preserves the interactive exec onboarding handoff", () => { expect(script).toMatch(/exec <\/dev\/tty\s+exec "\$claw" onboard/); }); 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 }); linkNodeExecutable(bin); 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 }); linkNodeExecutable(bin); 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 }); linkNodeExecutable(bin); mkdirSync(home, { recursive: true }); writeFileSync(join(home, ".npmrc"), "min-release-age=7\n"); writeNpmFreshnessConflictFixture(join(bin, "npm"), argsLog); let result: ReturnType | 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 }); linkNodeExecutable(bin); 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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; 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; 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 | 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 | 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 | 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"); }); });