openclaw/test/scripts/install-cli.test.ts
RoboClaw 4a5ec828b1
fix: keep updates working below recommended Node security patches (#186589)
* fix: keep updates working below recommended Node security patches

Restore the pre-security compatibility floors while preserving SQLite safety checks. Surface nonblocking Node security advisories in updates, Doctor, and security audit.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* fix: keep updates working below recommended Node security patches

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 9d2cfd56-3cfe-4d3e-8516-968cf5d46d9a

* fix: align Node compatibility consumers and CI coverage

Rename the audit collector, restore consumer expectations and recovered-runtime metadata, and consolidate equivalent tests without increasing the test-line ceiling.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* Publish openclaw/keen-prawn

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: c9fc6607-e7f5-47d5-811c-14a954645c1a

---------

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
2026-10-28 07:44:00 +00:00

1094 lines
40 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readStandaloneInstaller } from "../../scripts/lib/standalone-installers.mjs";
import { requireNodeTool } from "../helpers/node-toolchain.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import { writeNpmLifecycleFixture } from "./install-npm-fixtures.js";
import { findDarwinReexecBash } from "./install-reexec-fixtures.js";
import {
defineInstallerNpmConfigContract,
defineInstallerNpmFreshnessContract,
defineInstallerNpmRetryContract,
defineInstallerPnpmContract,
defineInstallerShellIsolationContract,
} from "./install-test-contract.js";
const SCRIPT_PATH = "scripts/install-cli.sh";
const nodeExecutable = requireNodeTool("node");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function runShell(script: string, env: NodeJS.ProcessEnv = {}) {
return spawnSync("/bin/bash", ["--noprofile", "--norc", "-c", script], {
encoding: "utf8",
env: {
...process.env,
ZDOTDIR: undefined,
OPENCLAW_INSTALL_CLI_SH_NO_RUN: "1",
...env,
BASH_ENV: "",
ENV: "",
},
});
}
function runInstallCliShell(script: string, env: NodeJS.ProcessEnv = {}) {
return runShell(`source "${join(process.cwd(), SCRIPT_PATH)}"\n${script}`, env);
}
function writeShell(path: string, body: string) {
writeFileSync(path, `#!/bin/bash\n${body}\n`, { mode: 0o755 });
}
function makeFixture(name: string) {
const root = tempDirs.make(`openclaw-install-cli-${name}-`);
const write = (relative: string, content: string, mode = 0o644) => {
const file = join(root, relative);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, content, { mode });
return file;
};
const shell = (relative: string, body: string, interpreter = "/bin/bash") =>
write(relative, `#!${interpreter}\n${body}\n`, 0o755);
return {
root,
write,
shell,
node(relative: string, version: string, sqliteExit = 0) {
const file = join(root, relative);
mkdirSync(dirname(file), { recursive: true });
writeNodeFixture(file, version, sqliteExit);
},
runtime(relative: string, npm = "exit 0", interpreter = "/bin/bash") {
const bin = join(root, relative);
shell(`${relative}/npm`, npm, interpreter);
symlinkSync(nodeExecutable, join(bin, "node"));
return bin;
},
run: (body: string, env: NodeJS.ProcessEnv = {}) =>
runInstallCliShell(body, { ROOT: root, FIXTURE_NODE: nodeExecutable, ...env }),
};
}
function linkRequiredShellTools(bin: string) {
for (const tool of ["ln", "mkdir"]) {
symlinkSync(`/bin/${tool}`, join(bin, tool));
}
}
function writeNodeFixture(filePath: string, version: string, sqliteExit = 0) {
writeShell(
filePath,
`
if [[ "\${1:-}" == -v ]]; then printf '%s\\n' '${version}';
elif [[ "\${1:-}" == -e ]]; then exit ${sqliteExit}; fi
exit 0
`,
);
}
function runNpmPolicy(spec: string) {
const f = makeFixture("lifecycle");
writeNpmLifecycleFixture(join(f.root, "npm"));
return f.run(
`
node_bin() { printf '%s\\n' "$FIXTURE_NODE"; }
cd "$ROOT"
npm_lifecycle_allow_arg "$ROOT/npm" "$FIXTURE_SPEC" "$PWD"
`,
{
FIXTURE_SPEC: spec,
NPM_FAKE_VERSION: "12.0.0",
NPM_FAKE_ARGS: join(f.root, "args"),
},
);
}
describe("install-cli.sh", () => {
const script = readStandaloneInstaller(process.cwd(), SCRIPT_PATH.slice("scripts/".length));
it.each([undefined, ".config/zsh"])("persists npm prefix PATH with ZDOTDIR=%s", (suffix) => {
const home = tempDirs.make("openclaw-cli-zdotdir-");
const zshDir = suffix ? join(home, suffix) : home;
mkdirSync(zshDir, { recursive: true });
const profiles = [join(home, ".bashrc"), join(zshDir, ".zshrc")];
for (const rc of profiles) {
writeFileSync(rc, "# existing\n");
}
const result = runInstallCliShell(
`
os_detect() { echo linux; }
npm_fixture() { [[ "$*" != "config get prefix" ]] || echo "$HOME/missing/prefix"; }
npm_bin() { echo npm_fixture; }
fix_npm_prefix_if_needed
fix_npm_prefix_if_needed
`,
{ HOME: home, ZDOTDIR: suffix ? zshDir : undefined },
);
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(profiles.map((rc) => readFileSync(rc, "utf8"))).toEqual(
Array(2).fill(`# existing\nexport PATH="${home}/.npm-global/bin:$PATH"\n`),
);
expect(existsSync(join(home, ".zshrc"))).toBe(!suffix);
});
const installerContract = {
scriptPath: SCRIPT_PATH,
runShell,
nodeExecutable,
prefix: true,
createTempDir: (prefix: string) => tempDirs.make(prefix),
};
defineInstallerShellIsolationContract(installerContract);
it("keeps runtime-only installation free of service and onboarding effects", () => {
const f = makeFixture("runtime-only");
f.shell(
"prefix/bin/openclaw",
`
printf '%s\\n' "$*" >> "$ROOT/commands"
if [[ "$1" == --version ]]; then printf 'OpenClaw 2026.9.4\\n'; fi
`,
);
const result = f.run(
`
install_node() { :; }; install_openclaw() { :; }
ensure_git() { printf 'git\\n' >> "$ROOT/commands"; }
refresh_gateway_service_if_loaded() { printf 'service-refresh\\n' >> "$ROOT/commands"; }
main --json --npm --onboard --runtime-only --prefix "$ROOT/prefix"
`,
);
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(readFileSync(join(f.root, "commands"), "utf8").trim().split("\n")).toEqual([
"--version",
]);
expect(result.stdout).toContain('"event":"done"');
});
it.each([
{ tempBase: "directory", downloadFails: false },
{ tempBase: "file", downloadFails: true },
])(
"owns and cleans private temporary storage with $tempBase TMPDIR (download fails: $downloadFails)",
({ tempBase, downloadFails }) => {
const f = makeFixture("temp");
const { root } = f;
chmodSync(root, 0o775);
const inheritedTemp = join(root, "inherited temp");
if (tempBase === "directory") {
mkdirSync(inheritedTemp, { mode: 0o755 });
} else if (tempBase === "file") {
writeFileSync(inheritedTemp, "preserve this file");
}
const prefix = join(root, "nested", "state");
f.shell("node-payload/bin/node", 'if [[ "${1:-}" == "-v" ]]; then printf "v24.22.1\\n"; fi');
f.shell("node-payload/bin/npm", "exit 0");
const archive = join(root, "node.tgz");
const packed = spawnSync("tar", ["-czf", archive, "-C", root, "node-payload"], {
encoding: "utf8",
});
expect(packed.status, packed.stdout + packed.stderr).toBe(0);
const digest = createHash("sha256").update(readFileSync(archive)).digest("hex");
const observationPath = join(root, "temporary-storage.json");
writeFileSync(
join(root, "observe-temp.cjs"),
`const fs = require("node:fs");
const path = require("node:path");
const tempDirectory = process.env.TMPDIR;
const metadata = fs.statSync(tempDirectory);
fs.mkdtempSync(path.join(tempDirectory, "child-"));
fs.writeFileSync(path.join(process.env.ROOT, "temporary-storage.json"), JSON.stringify({
tempDirectory, mode: metadata.mode & 0o777, uid: metadata.uid,
stagingDirectory: path.dirname(process.argv[2]),
}));
`,
);
try {
const result = f.run(
`
is_musl_linux() { return 1; }
umask 0002
detect_downloader() { :; }
preflight_fresh_git_disk_space() { exit 91; }
install_openclaw_from_git() { exit 92; }
install_openclaw() { exit 93; }
refresh_gateway_service_if_loaded() { exit 94; }
download_file() {
"$FIXTURE_NODE" "$ROOT/observe-temp.cjs" "$2" || return
if [[ "${downloadFails ? 1 : 0}" == 1 ]]; then return 42; fi
case "$1" in
*/SHASUMS256.txt)
printf '%s node-v24.22.1-%s-%s.tar.gz\\n' "$FIXTURE_SHA" "$(os_detect)" "$(arch_detect)" > "$2"
;;
*) cp "$ROOT/node.tgz" "$2" ;;
esac
}
main --json --node-only --git --onboard --node-version 24.22.1 --prefix "$ROOT/nested/state"
`,
{
HOME: root,
TMPDIR: inheritedTemp,
FIXTURE_SHA: digest,
},
);
expect(result.status, result.stdout + result.stderr).toBe(downloadFails ? 42 : 0);
expect(lstatSync(root).mode & 0o777).toBe(0o775);
for (const directory of [join(root, "nested"), prefix, join(prefix, "tools")]) {
expect(lstatSync(directory).mode & 0o777, directory).toBe(0o700);
}
const observation = JSON.parse(readFileSync(observationPath, "utf8")) as {
tempDirectory: string;
mode: number;
uid: number;
stagingDirectory: string;
};
expect(observation.tempDirectory).not.toBe(inheritedTemp);
expect(observation.mode).toBe(0o700);
expect(observation.uid).toBe(process.getuid?.());
expect(observation.stagingDirectory.startsWith(`${observation.tempDirectory}/`)).toBe(true);
expect(existsSync(observation.tempDirectory)).toBe(false);
expect(existsSync(observation.stagingDirectory)).toBe(false);
expect(existsSync(join(prefix, "tools", "node", "bin", "node"))).toBe(!downloadFails);
if (tempBase === "directory") {
expect(readdirSync(inheritedTemp)).toEqual([]);
} else if (tempBase === "file") {
expect(readFileSync(inheritedTemp, "utf8")).toBe("preserve this file");
} else {
expect(existsSync(inheritedTemp)).toBe(false);
}
} finally {
if (existsSync(observationPath)) {
const observation = JSON.parse(readFileSync(observationPath, "utf8")) as {
tempDirectory: string;
};
if (observation.tempDirectory !== inheritedTemp) {
rmSync(observation.tempDirectory, { recursive: true, force: true });
}
}
}
},
);
it.each([true, false])(
"preserves the caller TMPDIR for service refresh and onboarding (originally set: %s)",
(originallySet) => {
const f = makeFixture("temp-lifecycle");
const inheritedTemp = join(f.root, "inherited");
mkdirSync(inheritedTemp);
f.shell(
"cli",
`
if [[ "$1" == --version ]]; then
printf '2026.9.12\\n'
else
printf '%s' "\${TMPDIR-<unset>}" > "$ROOT/onboard-temp"
fi
`,
);
const result = f.run(
`
install_node() { printf '%s' "$TMPDIR" > "$ROOT/install-temp"; }
ensure_git() { :; }
install_openclaw() { mkdir -p "$PREFIX/bin"; cp "$ROOT/cli" "$PREFIX/bin/openclaw"; }
refresh_gateway_service_if_loaded() { printf '%s' "\${TMPDIR-<unset>}" > "$ROOT/refresh-temp"; }
main --onboard --prefix "$ROOT/prefix"
printf '%s' "\${TMPDIR-<unset>}" > "$ROOT/caller-temp"
`,
{
HOME: f.root,
TMPDIR: originallySet ? inheritedTemp : undefined,
OPENCLAW_NO_ONBOARD: "0",
},
);
expect(result.status, result.stdout + result.stderr).toBe(0);
for (const name of ["refresh-temp", "onboard-temp", "caller-temp"]) {
expect(readFileSync(join(f.root, name), "utf8")).toBe(
originallySet ? inheritedTemp : "<unset>",
);
}
const installTemp = readFileSync(join(f.root, "install-temp"), "utf8");
expect(installTemp).not.toBe(inheritedTemp);
expect(existsSync(installTemp)).toBe(false);
},
);
it("re-execs a streamed installer on Darwin Bash 5.3+ without leaving a temp file", (context) => {
const bash = findDarwinReexecBash();
if (!bash) {
context.skip("Requires a Darwin host with Bash 5.3+ installed");
return;
}
const tmp = tempDirs.make("openclaw-install-reexec-");
const result = spawnSync(bash, ["-s", "--", "--help"], {
input: script,
encoding: "utf8",
timeout: 10_000,
env: {
...process.env,
HOME: tmp,
TMPDIR: tmp,
BASH_ENV: "",
ENV: "",
OPENCLAW_INSTALL_SH_NO_RUN: "0",
OPENCLAW_INSTALL_CLI_SH_NO_RUN: "0",
},
});
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(result.stdout).toContain("Usage: install-cli.sh [options]");
expect(result.stderr).not.toContain("Run this installer with /bin/bash");
expect(readdirSync(tmp)).toEqual([]);
});
it("fails a low-space fresh Git install before Node or checkout work", () => {
const f = makeFixture("disk-low");
const result = f.run(`
available_disk_kib() { printf '2097152\\n'; }
install_node() { printf 'node\\n' >> "$ROOT/commands.log"; }
install_openclaw_from_git() { printf 'git\\n' >> "$ROOT/commands.log"; }
main --json --git --git-dir "$ROOT/new/openclaw"
`);
expect(result.status).toBe(1);
expect(existsSync(join(f.root, "commands.log"))).toBe(false);
const events = result.stdout
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as {
event: string;
name?: string;
message?: string;
},
);
expect(events).toEqual([
{ event: "step", name: "disk-space", status: "start" },
{
event: "error",
message:
"Fresh Git installs require at least 6 GiB of free disk space; only 2.0 GiB is available. Free disk space and retry.",
},
]);
});
it.each([false, true])(
"rejects unknown arguments with preserved status and JSON bytes (json=%s)",
(json) => {
const result = runInstallCliShell(`main ${json ? "--json " : ""}--synthetic-invalid`);
expect(result.status).toBe(1);
expect(result.stderr).toBe("");
expect(result.stdout).toBe(
json
? '{"event":"error","message":"Unknown option: --synthetic-invalid"}\n'
: "ERROR: Unknown option: --synthetic-invalid\nSupport: https://community.openclaw.ai/\n",
);
},
);
it.each([false, true])(
"surfaces captured npm-version support without changing JSON output (json=%s)",
(json) => {
const root = tempDirs.make("openclaw-install-cli-version-failure-");
const npm = join(root, "npm"),
args = join(root, "args");
writeNpmLifecycleFixture(npm);
const result = runInstallCliShell(
`
npm_bin() { printf '%s\\n' "$FIXTURE_NPM"; }
npm_config_has_raw_key() { return 1; }
JSON=${json ? 1 : 0}
install_openclaw
`,
{ FIXTURE_NPM: npm, NPM_FAKE_VERSION_STATUS: "1", NPM_FAKE_ARGS: args },
);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toBe(json ? "" : "Support: https://community.openclaw.ai/\n");
expect(existsSync(args)).toBe(false);
},
);
it("round-trips dynamic installer values through independent NDJSON records", () => {
const root = tempDirs.make("openclaw-install-cli-json-events-");
const dynamicValue = `quote"\\café项目lobster🦞${String.fromCharCode(
...Array.from({ length: 31 }, (_, index) => index + 1),
)}end`;
const fakeNode = join(root, "node");
writeFileSync(fakeNode, '#!/bin/bash\nprintf "%s" "$EVENT_VALUE"\n');
chmodSync(fakeNode, 0o755);
const success = runInstallCliShell(
[
"JSON=1",
"try_link_usable_node_runtime_from_path() { return 0; }",
`node_bin() { printf '%s\\n' ${JSON.stringify(fakeNode)}; }`,
"install_alpine_node",
'emit_json done version "$EVENT_VALUE"',
].join("\n"),
{ EVENT_VALUE: dynamicValue },
);
expect(success.status, success.stderr || success.stdout).toBe(0);
const successLines = success.stdout.trimEnd().split("\n");
expect(successLines).toHaveLength(3);
const successEvents = successLines.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(successEvents).toEqual([
{ event: "step", name: "node", status: "start", method: "apk" },
{ event: "step", name: "node", status: "ok", method: "system", version: dynamicValue },
{ event: "done", ok: true, version: dynamicValue },
]);
const failure = runInstallCliShell(["JSON=1", 'fail "$EVENT_VALUE"'].join("\n"), {
EVENT_VALUE: dynamicValue,
});
expect(failure.status).toBe(1);
expect(failure.stdout.trimEnd().split("\n")).toHaveLength(1);
expect(JSON.parse(failure.stdout)).toEqual({ event: "error", message: dynamicValue });
});
it("keeps the full Git install on the canonical checkout after an alias is retargeted", () => {
const root = tempDirs.make("openclaw-install-cli-retargeted-alias-");
const result = runInstallCliShell(
`
set -euo pipefail
target="$ROOT/target"
replacement="$ROOT/replacement"
alias_path="$ROOT/alias"
mkdir -p "$target" "$replacement"
ln -s "$target" "$alias_path"
PREFIX="$ROOT/prefix"
ensure_git() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }
checkout_git_openclaw_ref() {
[[ "$1" == "$target" && "$2" == "main" ]] || return 1
GIT_REF_KIND=moving
}
ensure_pnpm() { [[ "$1" == "$target" ]]; }
run_pnpm() {
[[ "$1" == "-C" && "$2" == "$target" ]] || return 1
if [[ "\${3:-}" == "install" ]]; then
[[ " $* " == *" --no-frozen-lockfile "* ]]
fi
}
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" "$PREFIX/bin/openclaw"
[[ -z "$(ls -A "$replacement")" ]]
[[ -z "$(find "$target" -maxdepth 1 -name '.openclaw-clone.*' -print -quit)" ]]
`,
{ ROOT: root },
);
expect(result.status, result.stderr || result.stdout).toBe(0);
});
it("bounds wget stalls and propagates timeout failures", () => {
const result = runInstallCliShell(`
set -euo pipefail
set +e
wget() {
printf 'wget=%s\n' "$*"
return 4
}
DOWNLOADER=wget
download_file "https://example.invalid/node.tar.gz" "/tmp/node.tar.gz"
printf 'wget-status=%s\n' "$?"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--timeout=300");
expect(result.stdout).toContain("wget-status=4");
});
it.each(["npm", "git"])("rejects incompatible %s before replacing the managed CLI", (method) => {
const f = makeFixture("compatible");
const candidate = "2026.7.1-2";
const openclaw = f.write("prefix/bin/openclaw", "existing-managed-cli\n");
mkdirSync(join(f.root, "repo/.git"), { recursive: true });
f.write("repo/package.json", JSON.stringify({ version: candidate }));
const result = f.run(`
PREFIX="$ROOT/prefix"
OPENCLAW_VERSION=latest
REQUIRED_COMPATIBLE_VERSION=2026.7.2
node_bin() { command -v node; }
npm_bin() { printf 'npm\\n'; }
npm_config_has_raw_key() { return 1; }
npm() {
if [[ "$1" == "view" ]]; then printf '%s\\n' '${candidate}'; return 0; fi
if [[ "$1" == "config" ]]; then printf 'null\\n'; return 0; fi
printf 'unexpected mutation: %s\\n' "$*" >&2
return 99
}
${
method === "npm"
? "install_openclaw"
: `
ensure_git() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }
checkout_git_openclaw_ref() { :; }
git() { return 0; }
ensure_pnpm() { printf 'unexpected mutation' >&2; return 99; }
install_openclaw_from_git "$ROOT/repo"
`
}
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("OpenClaw 2026.7.1-2 is older than config writer 2026.7.2");
expect(result.stderr).not.toContain("unexpected mutation");
expect(readFileSync(openclaw, "utf8")).toBe("existing-managed-cli\n");
});
it("reports runtime replacement without leaking paths or restarting again", () => {
const f = makeFixture("refresh");
f.shell(
"prefix/bin/openclaw",
`
printf '%s\\n' "$*" >> "$ROOT/commands"
if [[ "$*" == "gateway install --force" ]]; then
printf '%s\\n' incidental-output-canary
printf '%s\\n' 'Replacing unsupported Gateway service Node 22.23.1 (/old/node) with /new/node; refreshing the install.'
fi
`,
);
const result = f.run(
`
PREFIX="$ROOT/prefix"
is_gateway_daemon_loaded() { return 0; }
refresh_gateway_service_if_loaded
`,
);
expect(result.status).toBe(0);
expect(result.stderr).toContain("Gateway service Node runtime replaced.");
for (const privateValue of ["incidental-output-canary", "/old/node", "/new/node"]) {
expect(result.stdout + result.stderr).not.toContain(privateValue);
}
expect(readFileSync(join(f.root, "commands"), "utf8").trim().split("\n")).toEqual([
"gateway install --force",
"gateway status --probe --json",
]);
});
it.each([
{ error: "SERVICE_DEFINITION_SEALED: protected", args: "--json", stream: "stdout" },
{ error: "SERVICE_DEFINITION_UNKNOWN: inaccessible", args: "--json", stream: "stderr" },
])("handles a traced $error refresh in $stream", ({ args, error, stream }) => {
const f = makeFixture("definition");
const secretCanary = "installer-cli-secret-canary-never-render";
f.shell(
"prefix/bin/openclaw",
[
'printf "%s\\n" "$*" >> "$ROOT/commands"',
'if [[ "$1" == "--version" ]]; then printf "OpenClaw 2026.8.25\\n"; exit 0; fi',
'if [[ "$*" == "gateway install --force" ]]; then',
' printf "%s\\n" "Replacing unsupported Gateway service Node 22.23.1 (/old/node) with /new/node; refreshing the install."',
' if [[ "$SERVICE_STREAM" == stdout ]]; then printf "%s\\n" "$SERVICE_ERROR"; else printf "%s\\n" "$SERVICE_ERROR" >&2; fi',
' printf "%s\\n" "$SECRET_CANARY" >&2; exit 1',
"fi",
].join("\n"),
);
const result = f.run(
[
"install_node() { :; }; ensure_git() { :; }; install_openclaw() { :; }",
"is_gateway_daemon_loaded() { return 0; }",
"set -x",
`main ${args} --prefix "$ROOT/prefix"`,
].join("\n"),
{
SECRET_CANARY: secretCanary,
SERVICE_ERROR: error,
SERVICE_STREAM: stream,
},
);
expect(readFileSync(join(f.root, "commands"), "utf8").split("\n")).not.toContain(
"gateway restart",
);
expect(result.status).toBe(0);
expect(result.stderr).toContain("+ main");
expect(result.stdout + result.stderr).not.toContain(secretCanary);
expect(result.stdout + result.stderr).not.toContain("Gateway service Node runtime replaced");
expect(result.stderr).toContain("gateway service definition left unchanged");
expect(result.stderr).toContain(
error.includes("SEALED")
? "privileged deployment owner"
: "inspect service-definition access",
);
expect(result.stdout).toContain('"event":"done"');
expect(result.stdout).toContain('"reason":"definition-mutation-denied"');
});
it.each([false, true])(
"rejects an unusable installed CLI before service refresh (version exits nonzero: %s)",
(failedVersion) => {
const f = makeFixture("invalid-package");
const refreshLog = join(f.root, "gateway-refresh.log");
if (failedVersion) {
f.shell("prefix/bin/openclaw", 'printf "OpenClaw 2026.8.1\\n"\nexit 1');
}
const result = f.run(
[
"npm_lifecycle_allow_arg() { :; }",
failedVersion
? "install_node() { :; }; install_openclaw() { :; }"
: 'install_node() { mkdir -p "$(node_dir)/lib/node_modules/openclaw/dist"; : > "$(node_dir)/lib/node_modules/openclaw/dist/entry.js"; }',
"ensure_git() { :; }",
'npm_bin() { printf "/usr/bin/true\\n"; }',
`refresh_gateway_service_if_loaded() { touch ${JSON.stringify(refreshLog)}; }`,
'main --json --prefix "$ROOT/prefix" --version 0.0.0',
].join("\n"),
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("Installed OpenClaw CLI did not return a version");
expect(result.stdout).not.toContain('"event":"done"');
expect(result.stdout).not.toContain("OpenClaw installed.");
expect(existsSync(refreshLog)).toBe(false);
},
);
it.each([
{ input: "environment", method: "npm" },
{ input: "literal tilde", method: "git" },
] as const)(
"keeps a generated $method launcher working after $input supplied paths change cwd",
({ input, method }) => {
const f = makeFixture(`relative-${method}`);
const home = join(f.root, "home"),
installRoot = join(f.root, "install-root");
const tilde = input === "literal tilde";
const prefix = join(tilde ? home : installRoot, "openclaw-local");
const repo = join(tilde ? home : installRoot, "openclaw-source");
const nodeDir = join(prefix, "tools", "node-v24.22.1");
const legacy = join(home, "openclaw", "Peekaboo", "user.txt");
for (const dir of [
dirname(legacy),
installRoot,
join(repo, ".git"),
join(repo, "dist"),
join(f.root, "other-root"),
]) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(legacy, "keep this checkout");
f.runtime(
`${tilde ? "home" : "install-root"}/openclaw-local/tools/node-v24.22.1/bin`,
'if [[ "$1" == "--version" ]]; then printf "11.15.0\\n"; elif [[ "$1" == "config" ]]; then printf "null\\n"; fi',
);
symlinkSync("node-v24.22.1", join(prefix, "tools", "node"));
for (const entry of [
join(nodeDir, "lib/node_modules/openclaw/dist/entry.js"),
join(repo, "dist/entry.js"),
]) {
mkdirSync(dirname(entry), { recursive: true });
writeFileSync(entry, 'console.log("fixture cli");\n');
}
const result = runShell(
`
set -euo pipefail
cd "$ROOT/install-root"
source "${join(process.cwd(), SCRIPT_PATH)}"
install_node() { :; }; ensure_git() { :; }; refresh_gateway_service_if_loaded() { :; }
${
method === "git"
? `
preflight_fresh_git_disk_space() { :; }; ensure_pnpm() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }; checkout_git_openclaw_ref() { :; }
git_install_lockfile_flag() { printf '%s\\n' '--no-frozen-lockfile'; }
run_pnpm() { :; }; git() { return 0; }
`
: ""
}
main --${method} ${tilde ? '--prefix "~/openclaw-local" --git-dir "~/openclaw-source"' : ""}
cd "$ROOT/other-root"
"${prefix}/bin/openclaw" --version
`,
{
ROOT: f.root,
HOME: home,
OPENCLAW_HOME: home,
OPENCLAW_GIT_DIR: undefined,
OPENCLAW_PREFIX: tilde ? undefined : "openclaw-local",
},
);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(readFileSync(legacy, "utf8")).toBe("keep this checkout");
expect(result.stdout.trim().split("\n").at(-1)).toBe("fixture cli");
},
);
defineInstallerPnpmContract(installerContract);
it.each([false, true])("replaces an unusable musl runtime (apk required: %s)", (useApk) => {
const f = makeFixture("alpine");
const managedBin = join(f.root, "prefix/tools/node-v24.22.1/bin");
f.node("prefix/tools/node-v24.22.1/bin/node", "v24.22.1", 1);
f.node("old-bin/node", "v18.20.0", 1);
f.shell("old-bin/npm", "exit 0");
f.shell("bin/npm", "exit 0");
linkRequiredShellTools(join(f.root, "bin"));
f.shell(
"bin/apk",
`
printf '%s\\n' "$*" >> "$ROOT/apk.log"
${useApk ? 'printf new > "$ROOT/node-state"' : "exit 99"}
`,
);
if (useApk) {
f.shell(
"bin/node",
`
if [[ "\${1:-}" == -v ]]; then
if [[ -f "$ROOT/node-state" ]]; then printf 'v24.22.1\\n'; else printf 'v18.20.0\\n'; fi
elif [[ "\${1:-}" == -e ]]; then [[ -f "$ROOT/node-state" ]]; exit $?; fi
exit 0
`,
);
} else {
f.node("bin/node", "v24.22.1");
}
const result = f.run(`
export PATH="$ROOT/prefix/tools/node-v24.22.1/bin:$ROOT/old-bin:$ROOT/bin"
is_musl_linux() { return 0; }
is_root() { return ${useApk ? 0 : 1}; }
PREFIX="$ROOT/prefix"
APK_NODE_BIN_DIR="$ROOT/bin"
NODE_VERSION=24.22.1
install_node linux x64
`);
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(result.stdout.includes("Installing Node via apk")).toBe(useApk);
if (useApk) {
expect(readFileSync(join(f.root, "apk.log"), "utf8")).toContain("add --no-cache nodejs npm");
} else {
expect(existsSync(join(f.root, "apk.log"))).toBe(false);
}
for (const tool of ["node", "npm"]) {
expect(lstatSync(join(managedBin, tool)).isSymbolicLink()).toBe(true);
expect(readlinkSync(join(managedBin, tool))).toBe(join(f.root, "bin", tool));
}
});
it("skips PATH runtimes whose npm cannot start and invokes npm with the selected Node", () => {
const f = makeFixture("broken-npm");
const { root } = f;
const prefix = join(root, "nested", "prefix");
f.runtime("bad", 'printf "%s\\n" "$*" >> "$ROOT/bad.log"\nexit 42');
f.shell("good/node", 'printf "%s\\n" "$*" >> "$ROOT/node.log"\nexec "$FIXTURE_NODE" "$@"');
f.shell(
"good/npm",
'require("node:fs").appendFileSync(process.env.ROOT + "/good.log", process.argv.slice(2).join(" ") + "\\n");',
"/usr/bin/env node",
);
writeFileSync(join(root, "good/package.json"), '{"type":"commonjs"}\n');
chmodSync(root, 0o775);
const result = f.run(`
export PATH="$ROOT/bad:$ROOT/good:${process.env.PATH ?? ""}"
umask 0002
PREFIX="$ROOT/nested/prefix"
try_link_usable_node_runtime_from_path
printf 'umask=%s\n' "$(umask)"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("umask=0002");
expect(lstatSync(root).mode & 0o777).toBe(0o775);
expect(readFileSync(join(root, "bad.log"), "utf8")).toBe("--version\n");
expect(readFileSync(join(root, "good.log"), "utf8")).toBe("--version\n");
expect(readFileSync(join(root, "node.log"), "utf8")).toContain("npm --version");
const bin = join(prefix, "tools", "node-v24.22.1", "bin");
for (const directory of [
join(root, "nested"),
prefix,
join(prefix, "tools"),
dirname(bin),
bin,
]) {
expect(lstatSync(directory).mode & 0o777, directory).toBe(0o700);
}
expect(lstatSync(join(bin, "node")).isSymbolicLink()).toBe(true);
for (const tool of ["node", "npm"]) {
expect(readlinkSync(join(bin, tool))).toBe(join(root, "good", tool));
}
});
it("preserves the active runtime when apk provides unsafe SQLite", () => {
const root = tempDirs.make("openclaw-install-cli-preserve-runtime-");
const bin = join(root, "candidate");
const prefix = join(root, "prefix");
const oldRuntime = join(root, "old-runtime");
mkdirSync(bin);
linkRequiredShellTools(bin);
mkdirSync(join(oldRuntime, "bin"), { recursive: true });
symlinkSync(nodeExecutable, join(oldRuntime, "bin", "node"));
mkdirSync(join(prefix, "tools"), { recursive: true });
symlinkSync(oldRuntime, join(prefix, "tools", "node"));
writeShell(
join(bin, "node"),
'if [[ "$1" == -e ]]; then exit 1; fi\nexec "$FIXTURE_NODE" "$@"',
);
writeShell(join(bin, "npm"), "exit 0");
const result = runInstallCliShell(
`
PREFIX="$FIXTURE_PREFIX"
PATH="$FIXTURE_BIN"
export PATH
APK_NODE_BIN_DIR="$FIXTURE_BIN"
is_root() { return 0; }
apk() { printf 'apk called\\n'; }
install_alpine_node
`,
{ FIXTURE_PREFIX: prefix, FIXTURE_BIN: bin, FIXTURE_NODE: nodeExecutable },
);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stdout).toContain("apk called");
expect(result.stdout).toContain("Alpine Node package must provide Node >=");
expect(readlinkSync(join(prefix, "tools", "node"))).toBe(oldRuntime);
expect(readlinkSync(join(oldRuntime, "bin", "node"))).toBe(nodeExecutable);
expect(existsSync(join(prefix, "tools", "node-v24.22.1"))).toBe(false);
});
it("excludes active runtime aliases reached through an empty PATH entry", () => {
const f = makeFixture("runtime-alias");
const bin = f.runtime("system-bin");
const oldBin = f.runtime("old-runtime/bin");
const old = dirname(oldBin),
prefix = join(f.root, "prefix");
linkRequiredShellTools(bin);
for (const tool of ["npx", "corepack"]) {
f.shell(`old-runtime/bin/${tool}`, "exit 0");
}
mkdirSync(join(prefix, "tools"), { recursive: true });
symlinkSync(old, join(prefix, "tools", "node"));
symlinkSync(join(prefix, "tools", "node", "bin"), join(f.root, "alias-bin"));
const result = f.run(`
PREFIX="$ROOT/prefix"
cd "$ROOT/alias-bin"
export PATH=":$ROOT/system-bin"
try_link_usable_node_runtime_from_path
`);
expect(result.status, result.stdout + result.stderr).toBe(0);
const active = join(prefix, "tools", "node", "bin");
for (const tool of ["node", "npm"]) {
expect(readlinkSync(join(active, tool))).toBe(join(bin, tool));
}
for (const tool of ["npx", "corepack"]) {
expect(readdirSync(active)).not.toContain(tool);
expect(readFileSync(join(oldBin, tool), "utf8")).toBe("#!/bin/bash\nexit 0\n");
}
});
it("publishes usable runtime links for a relative PATH entry with spaces", () => {
const f = makeFixture("relative-runtime");
f.runtime("source dir/bin dir", "console.log('11.19.1');", "/usr/bin/env node");
const fallback = f.runtime("fallback", "console.log('11.19.2');", "/usr/bin/env node");
linkRequiredShellTools(fallback);
mkdirSync(join(f.root, "elsewhere"));
for (const tool of ["npx", "corepack"]) {
f.shell(`source dir/tools dir/${tool}`, `printf '${tool}-ok\\n'`);
}
const result = f.run(`
PREFIX="$ROOT/prefix"
cd "$ROOT/source dir"
export PATH="bin dir:tools dir:$ROOT/fallback"
try_link_usable_node_runtime_from_path
cd "$ROOT/elsewhere"
PATH="$PREFIX/tools/node/bin:$ROOT/fallback"
"$PREFIX/tools/node/bin/node" -p '"node-ok"'
"$PREFIX/tools/node/bin/npm" --version
"$PREFIX/tools/node/bin/npx"
"$PREFIX/tools/node/bin/corepack"
`);
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(result.stdout.trim()).toBe("node-ok\n11.19.1\nnpx-ok\ncorepack-ok");
for (const tool of ["node", "npm", "npx", "corepack"]) {
expect(readlinkSync(join(f.root, "prefix/tools/node/bin", tool))).toMatch(/^\//);
}
});
it.each(
["command", "runtime"].flatMap((route) =>
["empty", "trailing"].map((order) => ({ route, order })),
),
)("preserves PATH lookup order for $route with $order entries", ({ route, order }) => {
const f = makeFixture("path-order");
for (const name of ["current", "other"]) {
const bin = join(f.root, name);
mkdirSync(bin);
linkRequiredShellTools(bin);
if (name !== "other" || order !== "trailing") {
f.runtime(name, `printf '${name}-npm\\n'`);
}
}
const result = f.run(`
PREFIX="$ROOT/prefix"
cd "$ROOT/current"
export PATH="${order === "empty" ? "" : "$ROOT/other:"}"
${route === "command" ? 'selected="$(command_path_without_node_prefix npm)"' : 'try_link_usable_node_runtime_from_path; selected="$(npm_bin)"'}
"$selected" --version
`);
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(result.stdout.trim()).toBe("current-npm");
});
it("does not invent a cwd search after filtering an empty managed PATH entry", () => {
const f = makeFixture("filtered-path");
f.shell("prefix/tools/node-v24.22.1/bin/npm", "exit 0");
const result = f.run(`
PREFIX="$ROOT/prefix"
cd "$ROOT/prefix/tools/node-v24.22.1/bin"
export PATH=""
command_path_without_node_prefix npm 1
`);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stdout).toBe("");
});
it("preserves the active runtime until a downloaded replacement starts, then permits retry", () => {
const f = makeFixture("node-retry");
const prefix = join(f.root, "prefix");
const oldRuntime = join(prefix, "tools", "node-v22.23.2");
const active = join(prefix, "tools", "node");
const packageContent = '{"name":"openclaw","version":"2026.9.2"}\n';
const packageFile = f.write(
"prefix/tools/node-v22.23.2/lib/node_modules/openclaw/package.json",
packageContent,
);
f.node("prefix/tools/node-v22.23.2/bin/node", "v22.23.2");
symlinkSync(oldRuntime, active);
f.shell("new-node", "exit 126");
const install = () =>
f.run(
`
is_musl_linux() { return 1; }
detect_downloader() { :; }
require_bin() { :; }
download_file() {
case "$1" in
*/SHASUMS256.txt) printf 'fixture-sha node-v24.22.1-linux-x64.tar.gz\\n' > "$2" ;;
*) printf 'node tarball fixture\\n' > "$2" ;;
esac
}
sha256_file() { printf 'fixture-sha\\n'; }
tar() {
local dest=''
while [[ $# -gt 0 ]]; do
if [[ "$1" == '-C' ]]; then dest="$2"; shift 2; else shift; fi
done
mkdir -p "$dest/bin"
cp "$ROOT/new-node" "$dest/bin/node"
printf '#!/bin/bash\\nexit 0\\n' > "$dest/bin/npm"
chmod +x "$dest/bin/npm"
}
PREFIX="$ROOT/prefix"
NODE_VERSION=24.22.1
install_node linux x64
`,
);
const expectActive = (target: string, version: string) => {
expect(readlinkSync(active)).toBe(target);
expect(spawnSync(join(active, "bin/node"), ["-v"], { encoding: "utf8" }).stdout).toBe(
`${version}\n`,
);
expect(readFileSync(packageFile, "utf8")).toBe(packageContent);
};
const failed = install();
expect(failed.status).toBe(1);
expect(failed.stdout).toContain(
"Installed Node 24.22.1 must provide Node >= 24.16.0 with WAL-reset-safe SQLite",
);
expect(failed.stdout).toContain("found Node unknown, SQLite unavailable");
expectActive(oldRuntime, "v22.23.2");
f.node("new-node", "v24.22.1");
const retried = install();
expect(retried.status, retried.stdout + retried.stderr).toBe(0);
expectActive(join(prefix, "tools", "node-v24.22.1"), "v24.22.1");
});
it.each([
["openclaw@npm:@scope/candidate.tgz@1.0.0", "--allow-scripts=@scope/candidate.tgz"],
["vendor/repo.tgz", "--allow-scripts=vendor/repo.tgz"],
[
"https://example.invalid/openclaw.tgz",
"--allow-scripts=https://example.invalid/openclaw.tgz",
],
])("uses npm-resolved lifecycle identity for %s", (spec, expected) => {
const result = runNpmPolicy(spec);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe(expected);
});
defineInstallerNpmConfigContract(installerContract);
it.each([
[
"musl recovery",
"is_musl_linux() { return 0; }; install_node() { echo unexpected-node-install; }; main --node-only",
"unavailable on musl Linux",
"unexpected-node-install",
],
[
"ARMv7",
"NODE_VERSION_REQUESTED=1; os_detect() { echo linux; }; arch_detect() { echo armv7l; }; install_node() { echo unexpected-install; }; main",
"Linux ARMv7 is unsupported: official Node 24+ binaries are unavailable",
"unexpected-install",
],
[
"vulnerable Node",
"NODE_VERSION=24.14.1; install_node linux x64",
"Node 24.14.1 is unsupported; use Node 24.16.0+ or Node 26.1.0+.",
"Installing Node 24.14.1",
],
[
"missing option value",
"parse_args --prefix --no-onboard",
"Missing value for --prefix",
"unbound variable",
],
[
"npm source target",
"os_detect() { echo linux; }; OPENCLAW_VERSION=main; install_openclaw",
"npm installs do not support OpenClaw GitHub source targets",
"",
],
])("rejects %s before installation", (name, body, message, absent) => {
const result = runInstallCliShell(body);
expect(result.status).toBe(1);
expect(result.stdout).toContain(message);
if (absent) {
expect(result.stdout + result.stderr).not.toContain(absent);
}
if (name === "npm source target") {
expect(result.stdout).toContain("--install-method git --version main");
}
});
defineInstallerNpmRetryContract(installerContract);
defineInstallerNpmFreshnessContract(installerContract);
});