fix(install): keep stable channel when npm install retries (#124778)

* fix(installer): keep npm channel target immutable

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(install): fail after repeated CLI package install errors

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(installer): verify npm package publication

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(install): satisfy shellcheck for entry validation

* fix(installer): link the packaged OpenClaw launcher

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* test(installer): keep retry fixture version-valid

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-17 00:17:26 -07:00
committed by GitHub
parent 1027837d96
commit 2b2c11e748
5 changed files with 409 additions and 32 deletions
+8 -16
View File
@@ -1283,23 +1283,15 @@ install_openclaw() {
fix_npm_prefix_if_needed
fi
if [[ "${requested}" == "latest" ]]; then
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"; then
log "npm install openclaw@latest failed; retrying openclaw@next"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"retry\",\"version\":\"next\"}"
resolved_requested="next"
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
resolved_requested="$(resolve_npm_openclaw_version next)"
if [[ -z "$resolved_requested" ]]; then
fail "Could not resolve OpenClaw next before compatibility checking."
fi
require_openclaw_version_compatible "$resolved_requested"
fi
env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"
requested="next"
local installed_entry
installed_entry="$(node_dir)/lib/node_modules/openclaw/dist/entry.js"
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" || [[ ! -f "$installed_entry" ]]; then
log "npm install openclaw@${resolved_requested} did not produce a usable package; retrying once"
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" || [[ ! -f "$installed_entry" ]]; then
emit_json '{"event":"error","message":"npm install did not produce a usable OpenClaw package"}'
log "ERROR: npm install did not produce a usable OpenClaw package"
return 1
fi
else
env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"
fi
mkdir -p "${PREFIX}/bin"
+9 -15
View File
@@ -2169,7 +2169,8 @@ fix_npm_permissions() {
ensure_openclaw_bin_link() {
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
if [[ -z "$npm_root" || ! -d "$npm_root/openclaw" ]]; then
local launcher="${npm_root}/openclaw/openclaw.mjs"
if [[ -z "$npm_root" || ! -x "$launcher" ]]; then
return 1
fi
local npm_bin=""
@@ -2179,10 +2180,10 @@ ensure_openclaw_bin_link() {
fi
mkdir -p "$npm_bin"
if [[ ! -x "${npm_bin}/openclaw" ]]; then
ln -sf "$npm_root/openclaw/dist/entry.js" "${npm_bin}/openclaw"
ln -sf "$launcher" "${npm_bin}/openclaw"
ui_info "Created openclaw bin link at ${npm_bin}/openclaw"
fi
return 0
"${npm_bin}/openclaw" --version >/dev/null 2>&1
}
# Check for existing OpenClaw installation
@@ -3040,22 +3041,15 @@ install_openclaw() {
local install_spec=""
install_spec="$(resolve_package_install_spec "${package_name}" "${OPENCLAW_VERSION}")"
if ! install_openclaw_npm "${install_spec}"; then
ui_warn "npm install failed; retrying"
if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then
ui_warn "npm install did not produce a usable OpenClaw package; retrying"
cleanup_npm_openclaw_paths
install_openclaw_npm "${install_spec}"
fi
if [[ "${OPENCLAW_VERSION}" == "latest" && "${package_name}" == "openclaw" ]]; then
if ! resolve_openclaw_bin &> /dev/null; then
ui_warn "npm install openclaw@latest failed; retrying openclaw@next"
cleanup_npm_openclaw_paths
install_openclaw_npm "openclaw@next"
if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then
ui_error "npm install did not produce a usable OpenClaw package"
return 1
fi
fi
ensure_openclaw_bin_link || true
ui_success "OpenClaw installed"
}
+143 -1
View File
@@ -19,6 +19,7 @@ import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
writeNpmInstallRetryFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install-cli.sh";
@@ -41,6 +42,12 @@ function linkRequiredShellTools(bin: string) {
}
}
function writeInstalledOpenClawEntry(nodeDir: string) {
const entry = join(nodeDir, "lib", "node_modules", "openclaw", "dist", "entry.js");
mkdirSync(join(entry, ".."), { recursive: true });
writeFileSync(entry, "");
}
describe("install-cli.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -494,7 +501,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"install_node() { :; }",
'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)}; }`,
@@ -1442,6 +1449,7 @@ describe("install-cli.sh", () => {
const nodeDir = join(tmp, "node");
mkdirSync(bin, { recursive: true });
mkdirSync(nodeDir, { recursive: true });
writeInstalledOpenClawEntry(nodeDir);
writeFileSync(npmrc, "min-release-age=7\n");
const fakeNpm = join(bin, "npm");
writeFileSync(
@@ -1517,6 +1525,7 @@ describe("install-cli.sh", () => {
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(nodeDir, { recursive: true });
writeInstalledOpenClawEntry(nodeDir);
if (source === "global") {
mkdirSync(join(prefix, "etc"), { recursive: true });
}
@@ -1598,6 +1607,137 @@ describe("install-cli.sh", () => {
expect(result.stdout).toContain("--install-method git --version main");
});
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 }) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-npm-retry-"));
const fakeNpm = join(tmp, "npm");
const calls = join(tmp, "calls");
const nodeDir = join(tmp, "node");
const prefix = join(tmp, "prefix");
writeNpmInstallRetryFixture(fakeNpm);
try {
const result = runInstallCliShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`,
`node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`,
"npm_config_has_raw_key() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`,
`OPENCLAW_VERSION=${requested}`,
"JSON=1",
"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(nodeDir, "lib", "node_modules", "openclaw"),
},
);
expect(result.status).toBe(status);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual(
Array.from({ length: expectedCalls }, () => `openclaw@${requested}`),
);
if (status !== 0) {
expect(result.stderr).toContain(`${error} (attempt 2)`);
expect(result.stdout).not.toContain('"status":"ok"');
expect(existsSync(join(prefix, "bin", "openclaw"))).toBe(false);
}
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-cli-empty-success-"));
const fakeNpm = join(tmp, "npm");
const calls = join(tmp, "calls");
const nodeDir = join(tmp, "node");
const prefix = join(tmp, "prefix");
writeNpmInstallRetryFixture(fakeNpm);
try {
const result = runInstallCliShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`,
`node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`,
"npm_config_has_raw_key() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`,
"OPENCLAW_VERSION=latest",
"JSON=1",
"install_openclaw",
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: "",
NPM_FAKE_OUTCOME: "success",
},
);
expect(result.status).toBe(1);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual([
"openclaw@latest",
"openclaw@latest",
]);
expect(result.stdout).toContain("npm install did not produce a usable OpenClaw package");
expect(result.stdout).not.toContain('"status":"ok"');
expect(result.stdout).not.toContain("openclaw@next");
expect(existsSync(join(prefix, "bin", "openclaw"))).toBe(false);
} 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-cli-freshness-"));
const prefix = join(tmp, "prefix");
@@ -1606,6 +1746,7 @@ describe("install-cli.sh", () => {
const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
writeInstalledOpenClawEntry(join(prefix, "tools", "node-v24.15.0"));
writeFileSync(join(home, ".npmrc"), "min-release-age=7\n");
writeNpmFreshnessConflictFixture(join(nodeBin, "npm"), argsLog);
@@ -1643,6 +1784,7 @@ describe("install-cli.sh", () => {
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
writeInstalledOpenClawEntry(join(prefix, "tools", "node-v24.15.0"));
writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n");
writeFileSync(join(project, ".npmrc"), "min-release-age=7\n");
writeNpmBeforePolicyFixture(join(nodeBin, "npm"), argsLog);
+32
View File
@@ -1,6 +1,38 @@
// NPM CLI fixture writers used by installer shell-script tests.
import { chmodSync, writeFileSync } from "node:fs";
export function writeNpmInstallRetryFixture(path: string) {
writeFileSync(
path,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1:-}" == "config" ]]; then printf "null\\n"; exit 0; fi',
'if [[ "${1:-}" == "view" ]]; then printf "2026.8.1\\n"; exit 0; fi',
'if [[ "${1:-}" == "root" ]]; then printf "%s\\n" "${NPM_FAKE_ROOT:-}"; exit 0; fi',
"is_install=0",
'for arg in "$@"; do [[ "$arg" == "install" ]] && is_install=1; done',
'if [[ "$is_install" -eq 0 ]]; then exit 0; fi',
'spec="${!#}"',
'printf "%s\\n" "$spec" >> "$NPM_FAKE_CALLS"',
'attempt="$(wc -l < "$NPM_FAKE_CALLS")"',
'if [[ "$NPM_FAKE_OUTCOME" == "success" || "$NPM_FAKE_OUTCOME" == "transient" && "$attempt" -eq 2 ]]; then',
' if [[ -n "${NPM_FAKE_PACKAGE_DIR:-}" ]]; then',
' mkdir -p "$NPM_FAKE_PACKAGE_DIR/dist"',
' printf "#!/bin/sh\\nprintf \'2026.8.1\\\\n\'\\n" > "$NPM_FAKE_PACKAGE_DIR/openclaw.mjs"',
' printf "#!/usr/bin/env node\\n" > "$NPM_FAKE_PACKAGE_DIR/dist/entry.js"',
' chmod +x "$NPM_FAKE_PACKAGE_DIR/openclaw.mjs" "$NPM_FAKE_PACKAGE_DIR/dist/entry.js"',
" fi",
" exit 0",
"fi",
'printf "%s (attempt %s)\\n" "$NPM_FAKE_ERROR" "$attempt" >&2',
"exit 1",
"",
].join("\n"),
);
chmodSync(path, 0o755);
}
export function writeNpmFreshnessConflictFixture(path: string, argsLog: string) {
writeFileSync(
path,
+217
View File
@@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest";
import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
writeNpmInstallRetryFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install.sh";
@@ -1343,6 +1344,222 @@ NODE
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).toBe(7);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it.each([
{ requested: "latest", outcome: "success", error: "", calls: 1, status: 0 },
{
requested: "beta",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "next",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "2026.8.1",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "latest",
outcome: "persistent",
error: "EACCES permission denied",
calls: 2,
status: 1,
},
{
requested: "beta",
outcome: "persistent",
error: "ENOSPC no space left",
calls: 2,
status: 1,
},
])(
"keeps openclaw@$requested immutable across $outcome npm installs",
({ requested, outcome, error, calls: expectedCalls, status: expectedStatus }) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-retry-"));
const bin = join(tmp, "bin");
const calls = join(tmp, "calls");
const npmRoot = join(tmp, "lib", "node_modules");
mkdirSync(bin, { recursive: true });
writeNpmInstallRetryFixture(join(bin, "npm"));
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
`OPENCLAW_VERSION=${requested}`,
"USE_BETA=0",
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"set +e",
"install_openclaw",
"status=$?",
"exit $status",
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: error,
NPM_FAKE_OUTCOME: outcome,
NPM_FAKE_PACKAGE_DIR: join(npmRoot, "openclaw"),
NPM_FAKE_ROOT: npmRoot,
},
);
expect(result.status).toBe(expectedStatus);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual(
Array.from({ length: expectedCalls }, () => `openclaw@${requested}`),
);
if (expectedStatus !== 0) {
expect(`${result.stdout}\n${result.stderr}`).toContain(`${error} (attempt 2)`);
}
if (requested !== "next") {
expect(`${result.stdout}\n${result.stderr}`).not.toContain("openclaw@next");
}
} finally {
rmSync(tmp, { force: true, recursive: true });
}
},
);
it("fails after retrying the exact npm spec when npm exits zero without installing OpenClaw", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-empty-success-"));
const bin = join(tmp, "bin");
const calls = join(tmp, "calls");
const npmRoot = join(tmp, "lib", "node_modules");
mkdirSync(bin, { recursive: true });
writeNpmInstallRetryFixture(join(bin, "npm"));
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"OPENCLAW_VERSION=latest",
"USE_BETA=0",
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`,
"install_openclaw",
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: "",
NPM_FAKE_OUTCOME: "success",
NPM_FAKE_ROOT: npmRoot,
},
);
expect(result.status).toBe(1);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual([
"openclaw@latest",
"openclaw@latest",
]);
expect(`${result.stdout}\n${result.stderr}`).toContain(
"npm install did not produce a usable OpenClaw package",
);
expect(`${result.stdout}\n${result.stderr}`).not.toContain("openclaw@next");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit before args when npmrc min-release-age computes a before cutoff", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-freshness-"));
const bin = join(tmp, "bin");