fix: verify global npm correction installs

This commit is contained in:
Peter Steinberger 2026-03-23 21:03:28 -07:00
parent 50d996a6ec
commit ce49d8bca9
No known key found for this signature in database
10 changed files with 453 additions and 14 deletions

View File

@ -72,9 +72,20 @@ pnpm test:install:smoke
For a non-root smoke path:
```bash
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
```
After npm publish, run:
```bash
node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
```
- This verifies the published registry install path in a fresh temp prefix.
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
silently leave existing global installs on the old base stable payload.
## Check all relevant release builds
- Always validate the OpenClaw npm release path before creating the tag.

View File

@ -42,6 +42,14 @@ OpenClaw has three public release lanes:
- Run `pnpm release:check` before every tagged release
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
(or the matching beta/correction tag) before approval
- After npm publish, run
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.D`
(or the matching beta/correction version) to verify the published registry
install path in a fresh temp prefix
- For stable correction releases like `YYYY.M.D-N`, the post-publish verifier
also checks the same temp-prefix upgrade path from `YYYY.M.D` to `YYYY.M.D-N`
so release corrections cannot silently leave older global installs on the
base stable payload
- npm release preflight fails closed unless the tarball includes both
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
so we do not ship an empty browser dashboard again

View File

@ -684,6 +684,7 @@
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "pnpm config:docs:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",
"stage:bundled-plugin-runtime-deps": "node scripts/stage-bundled-plugin-runtime-deps.mjs",

View File

@ -0,0 +1,157 @@
#!/usr/bin/env -S node --import tsx
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts";
const REQUIRED_RUNTIME_SIDECARS = [
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/msteams/runtime-api.js",
] as const;
type InstalledPackageJson = {
version?: string;
};
export type PublishedInstallScenario = {
name: string;
installSpecs: string[];
expectedVersion: string;
};
export function buildPublishedInstallScenarios(version: string): PublishedInstallScenario[] {
const parsed = parseReleaseVersion(version);
if (parsed === null) {
throw new Error(`Unsupported release version "${version}".`);
}
const exactSpec = `openclaw@${version}`;
const scenarios: PublishedInstallScenario[] = [
{
name: "fresh-exact",
installSpecs: [exactSpec],
expectedVersion: version,
},
];
if (parsed.channel === "stable" && parsed.correctionNumber !== undefined) {
scenarios.push({
name: "upgrade-from-base-stable",
installSpecs: [`openclaw@${parsed.baseVersion}`, exactSpec],
expectedVersion: version,
});
}
return scenarios;
}
export function collectInstalledPackageErrors(params: {
expectedVersion: string;
installedVersion: string;
packageRoot: string;
}): string[] {
const errors: string[] = [];
if (params.installedVersion !== params.expectedVersion) {
errors.push(
`installed package version mismatch: expected ${params.expectedVersion}, found ${params.installedVersion || "<missing>"}.`,
);
}
for (const relativePath of REQUIRED_RUNTIME_SIDECARS) {
if (!existsSync(join(params.packageRoot, relativePath))) {
errors.push(`installed package is missing required bundled runtime sidecar: ${relativePath}`);
}
}
return errors;
}
function npmExec(args: string[], cwd: string): string {
const invocation = resolveNpmCommandInvocation({
npmExecPath: process.env.npm_execpath,
nodeExecPath: process.execPath,
platform: process.platform,
});
return execFileSync(invocation.command, [...invocation.args, ...args], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function resolveGlobalRoot(prefixDir: string, cwd: string): string {
return npmExec(["root", "-g", "--prefix", prefixDir], cwd);
}
function installSpec(prefixDir: string, spec: string, cwd: string): void {
npmExec(["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"], cwd);
}
function verifyScenario(version: string, scenario: PublishedInstallScenario): void {
const workingDir = mkdtempSync(join(tmpdir(), `openclaw-postpublish-${scenario.name}.`));
const prefixDir = join(workingDir, "prefix");
try {
for (const spec of scenario.installSpecs) {
installSpec(prefixDir, spec, workingDir);
}
const globalRoot = resolveGlobalRoot(prefixDir, workingDir);
const packageRoot = join(globalRoot, "openclaw");
const pkg = JSON.parse(
readFileSync(join(packageRoot, "package.json"), "utf8"),
) as InstalledPackageJson;
const errors = collectInstalledPackageErrors({
expectedVersion: scenario.expectedVersion,
installedVersion: pkg.version?.trim() ?? "",
packageRoot,
});
if (errors.length > 0) {
throw new Error(`${scenario.name} failed:\n- ${errors.join("\n- ")}`);
}
console.log(`openclaw-npm-postpublish-verify: ${scenario.name} OK (${version})`);
} finally {
rmSync(workingDir, { force: true, recursive: true });
}
}
function main(): void {
const version = process.argv[2]?.trim();
if (!version) {
throw new Error(
"Usage: node --import tsx scripts/openclaw-npm-postpublish-verify.ts <version>",
);
}
const scenarios = buildPublishedInstallScenarios(version);
for (const scenario of scenarios) {
verifyScenario(version, scenario);
}
console.log(
`openclaw-npm-postpublish-verify: verified published npm install paths for ${version}.`,
);
}
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
if (entrypoint !== null && import.meta.url === entrypoint) {
try {
main();
} catch (error) {
console.error(
`openclaw-npm-postpublish-verify: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}

View File

@ -28,6 +28,14 @@ const pathExists = vi.fn();
const syncPluginsForUpdateChannel = vi.fn();
const updateNpmInstalledPlugins = vi.fn();
const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture();
const REQUIRED_BUNDLED_RUNTIME_SIDECARS = [
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/msteams/runtime-api.js",
] as const;
vi.mock("@clack/prompts", () => ({
confirm,
@ -615,6 +623,58 @@ describe("update-cli", () => {
}
});
it("fails package updates when the installed correction version does not match the requested target", async () => {
const tempDir = createCaseDir("openclaw-update");
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
mockPackageInstallStatus(tempDir);
await fs.mkdir(pkgRoot, { recursive: true });
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.3.23" }),
"utf-8",
);
for (const relativePath of REQUIRED_BUNDLED_RUNTIME_SIDECARS) {
const absolutePath = path.join(pkgRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf-8");
}
readPackageVersion.mockResolvedValue("2026.3.23");
pathExists.mockImplementation(async (candidate: string) =>
REQUIRED_BUNDLED_RUNTIME_SIDECARS.some(
(relativePath) => candidate === path.join(pkgRoot, relativePath),
),
);
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return {
stdout: nodeModules,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true, tag: "2026.3.23-2" });
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(writeConfigFile).not.toHaveBeenCalled();
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
expect(logs.join("\n")).toContain("global install verify");
expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23");
});
it("prepends portable Git PATH for package updates on Windows", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const tempDir = createCaseDir("openclaw-update");

View File

@ -24,10 +24,12 @@ import {
checkUpdateStatus,
} from "../../infra/update-check.js";
import {
collectInstalledGlobalPackageErrors,
canResolveRegistryVersionForPackageTarget,
createGlobalInstallEnv,
cleanupGlobalRenameDirs,
globalInstallArgs,
resolveExpectedInstalledVersionFromSpec,
resolveGlobalInstallSpec,
resolveGlobalPackageRoot,
} from "../../infra/update-global.js";
@ -343,9 +345,27 @@ async function runPackageInstallUpdate(params: {
const steps = [updateStep];
let afterVersion = beforeVersion;
if (pkgRoot) {
afterVersion = await readPackageVersion(pkgRoot);
const entryPath = path.join(pkgRoot, "dist", "entry.js");
const verifiedPackageRoot =
(await resolveGlobalPackageRoot(manager, runCommand, params.timeoutMs)) ?? pkgRoot;
if (verifiedPackageRoot) {
afterVersion = await readPackageVersion(verifiedPackageRoot);
const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, installSpec);
const verificationErrors = await collectInstalledGlobalPackageErrors({
packageRoot: verifiedPackageRoot,
expectedVersion,
});
if (verificationErrors.length > 0) {
steps.push({
name: "global install verify",
command: `verify ${verifiedPackageRoot}`,
cwd: verifiedPackageRoot,
durationMs: 0,
exitCode: 1,
stderrTail: verificationErrors.join("\n"),
stdoutTail: null,
});
}
const entryPath = path.join(verifiedPackageRoot, "dist", "entry.js");
if (await pathExists(entryPath)) {
const doctorStep = await runUpdateStep({
name: `${CLI_NAME} doctor`,
@ -361,7 +381,7 @@ async function runPackageInstallUpdate(params: {
return {
status: failedStep ? "error" : "ok",
mode: manager,
root: pkgRoot ?? params.root,
root: verifiedPackageRoot ?? params.root,
reason: failedStep ? failedStep.name : undefined,
before: { version: beforeVersion },
after: { version: afterVersion },

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathExists } from "../utils.js";
import { readPackageVersion } from "./package-json.js";
import { applyPathPrepend } from "./path-prepend.js";
export type GlobalInstallManager = "npm" | "pnpm" | "bun";
@ -15,6 +16,14 @@ const PRIMARY_PACKAGE_NAME = "openclaw";
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
const GLOBAL_RENAME_PREFIX = ".";
export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main";
const REQUIRED_BUNDLED_RUNTIME_SIDECARS = [
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/msteams/runtime-api.js",
] as const;
const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const;
const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
"--omit=optional",
@ -41,6 +50,47 @@ export function isExplicitPackageInstallSpec(value: string): boolean {
);
}
export function resolveExpectedInstalledVersionFromSpec(
packageName: string,
spec: string,
): string | null {
const normalizedPackageName = packageName.trim();
const normalizedSpec = normalizePackageTarget(spec);
if (!normalizedPackageName || !normalizedSpec.startsWith(`${normalizedPackageName}@`)) {
return null;
}
const rawVersion = normalizedSpec.slice(normalizedPackageName.length + 1).trim();
if (
!rawVersion ||
rawVersion.includes("/") ||
rawVersion.includes(":") ||
rawVersion.includes("#") ||
/^(latest|beta|next|main)$/i.test(rawVersion)
) {
return null;
}
return rawVersion;
}
export async function collectInstalledGlobalPackageErrors(params: {
packageRoot: string;
expectedVersion?: string | null;
}): Promise<string[]> {
const errors: string[] = [];
const installedVersion = await readPackageVersion(params.packageRoot);
if (params.expectedVersion && installedVersion !== params.expectedVersion) {
errors.push(
`expected installed version ${params.expectedVersion}, found ${installedVersion ?? "<missing>"}`,
);
}
for (const relativePath of REQUIRED_BUNDLED_RUNTIME_SIDECARS) {
if (!(await pathExists(path.join(params.packageRoot, relativePath)))) {
errors.push(`missing bundled runtime sidecar ${relativePath}`);
}
}
return errors;
}
export function canResolveRegistryVersionForPackageTarget(value: string): boolean {
const trimmed = normalizePackageTarget(value);
if (!trimmed) {

View File

@ -9,6 +9,14 @@ import { runGatewayUpdate } from "./update-runner.js";
type CommandResponse = { stdout?: string; stderr?: string; code?: number | null };
type CommandResult = { stdout: string; stderr: string; code: number | null };
const REQUIRED_BUNDLED_RUNTIME_SIDECARS = [
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/msteams/runtime-api.js",
] as const;
function createRunner(responses: Record<string, CommandResponse>) {
const calls: string[] = [];
@ -185,6 +193,7 @@ describe("runGatewayUpdate", () => {
JSON.stringify({ name: "openclaw", version }),
"utf-8",
);
await writeBundledRuntimeSidecars(pkgRoot);
}
async function writeGlobalPackageVersion(pkgRoot: string, version = "2.0.0") {
@ -193,6 +202,15 @@ describe("runGatewayUpdate", () => {
JSON.stringify({ name: "openclaw", version }),
"utf-8",
);
await writeBundledRuntimeSidecars(pkgRoot);
}
async function writeBundledRuntimeSidecars(pkgRoot: string) {
for (const relativePath of REQUIRED_BUNDLED_RUNTIME_SIDECARS) {
const absolutePath = path.join(pkgRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf-8");
}
}
async function createGlobalPackageFixture(rootDir: string) {
@ -660,11 +678,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "node-gyp failed", code: 1 };
},
onOmitOptionalInstall: async () => {
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
await writeGlobalPackageVersion(pkgRoot);
return { stdout: "ok", stderr: "", code: 0 };
},
});
@ -680,6 +694,47 @@ describe("runGatewayUpdate", () => {
]);
});
it("fails global npm update when the installed version misses the requested correction", async () => {
const { calls, result } = await runNpmGlobalUpdateCase({
expectedInstallCommand: "npm i -g openclaw@2026.3.23-2 --no-fund --no-audit --loglevel=error",
tag: "2026.3.23-2",
});
expect(result.status).toBe("error");
expect(result.reason).toBe("global install verify");
expect(result.after?.version).toBe("2.0.0");
expect(result.steps.at(-1)?.stderrTail).toContain(
"expected installed version 2026.3.23-2, found 2.0.0",
);
expect(calls).toContain("npm i -g openclaw@2026.3.23-2 --no-fund --no-audit --loglevel=error");
});
it("fails global npm update when bundled runtime sidecars are missing after install", async () => {
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
const expectedInstallCommand = "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error";
const { runCommand } = createGlobalInstallHarness({
pkgRoot,
npmRootOutput: nodeModules,
installCommand: expectedInstallCommand,
onInstall: async () => {
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
await fs.rm(path.join(pkgRoot, "dist"), { recursive: true, force: true });
},
});
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
expect(result.status).toBe("error");
expect(result.reason).toBe("global install verify");
expect(result.steps.at(-1)?.stderrTail).toContain(
"missing bundled runtime sidecar dist/extensions/whatsapp/light-runtime-api.js",
);
});
it("prepends portable Git PATH for global Windows npm updates", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const localAppData = path.join(tempDir, "local-app-data");

View File

@ -21,12 +21,15 @@ import {
} from "./update-channels.js";
import { compareSemverStrings } from "./update-check.js";
import {
collectInstalledGlobalPackageErrors,
cleanupGlobalRenameDirs,
createGlobalInstallEnv,
detectGlobalInstallManagerForRoot,
globalInstallArgs,
globalInstallFallbackArgs,
resolveExpectedInstalledVersionFromSpec,
resolveGlobalInstallSpec,
resolveGlobalPackageRoot,
} from "./update-global.js";
export type UpdateStepResult = {
@ -1045,12 +1048,34 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
}
const afterVersion = await readPackageVersion(pkgRoot);
const verifiedPackageRoot =
(await resolveGlobalPackageRoot(globalManager, runCommand, timeoutMs)) ?? pkgRoot;
const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, spec);
const verificationErrors = await collectInstalledGlobalPackageErrors({
packageRoot: verifiedPackageRoot,
expectedVersion,
});
if (verificationErrors.length > 0) {
steps.push({
name: "global install verify",
command: `verify ${verifiedPackageRoot}`,
cwd: verifiedPackageRoot,
durationMs: 0,
exitCode: 1,
stderrTail: verificationErrors.join("\n"),
});
}
const afterVersion = await readPackageVersion(verifiedPackageRoot);
const failedStep =
finalStep.exitCode !== 0
? finalStep
: (steps.find((step) => step.name === "global install verify" && step.exitCode !== 0) ??
null);
return {
status: finalStep.exitCode === 0 ? "ok" : "error",
status: failedStep ? "error" : "ok",
mode: globalManager,
root: pkgRoot,
reason: finalStep.exitCode === 0 ? undefined : finalStep.name,
root: verifiedPackageRoot,
reason: failedStep ? failedStep.name : undefined,
before: { version: beforeVersion },
after: { version: afterVersion },
steps,

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
buildPublishedInstallScenarios,
collectInstalledPackageErrors,
} from "../scripts/openclaw-npm-postpublish-verify.ts";
describe("buildPublishedInstallScenarios", () => {
it("uses a single fresh scenario for plain stable releases", () => {
expect(buildPublishedInstallScenarios("2026.3.23")).toEqual([
{
name: "fresh-exact",
installSpecs: ["openclaw@2026.3.23"],
expectedVersion: "2026.3.23",
},
]);
});
it("adds a stable-to-correction upgrade scenario for correction releases", () => {
expect(buildPublishedInstallScenarios("2026.3.23-2")).toEqual([
{
name: "fresh-exact",
installSpecs: ["openclaw@2026.3.23-2"],
expectedVersion: "2026.3.23-2",
},
{
name: "upgrade-from-base-stable",
installSpecs: ["openclaw@2026.3.23", "openclaw@2026.3.23-2"],
expectedVersion: "2026.3.23-2",
},
]);
});
});
describe("collectInstalledPackageErrors", () => {
it("flags version mismatches and missing runtime sidecars", () => {
expect(
collectInstalledPackageErrors({
expectedVersion: "2026.3.23-2",
installedVersion: "2026.3.23",
packageRoot: "/tmp/empty-openclaw",
}),
).toEqual([
"installed package version mismatch: expected 2026.3.23-2, found 2026.3.23.",
"installed package is missing required bundled runtime sidecar: dist/extensions/whatsapp/light-runtime-api.js",
"installed package is missing required bundled runtime sidecar: dist/extensions/whatsapp/runtime-api.js",
"installed package is missing required bundled runtime sidecar: dist/extensions/matrix/helper-api.js",
"installed package is missing required bundled runtime sidecar: dist/extensions/matrix/runtime-api.js",
"installed package is missing required bundled runtime sidecar: dist/extensions/matrix/thread-bindings-runtime.js",
"installed package is missing required bundled runtime sidecar: dist/extensions/msteams/runtime-api.js",
]);
});
});