From c99d72575eced4f93ad0e4fb1ebe5c9855587bc8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:07:53 +0100 Subject: [PATCH] fix(release): reject staged runtime deps in packs --- CHANGELOG.md | 1 + package.json | 1 + scripts/openclaw-cross-os-release-checks.ts | 4 +- scripts/release-check.ts | 2 + src/infra/package-dist-inventory.test.ts | 162 ++++++++++++++++++ src/infra/package-dist-inventory.ts | 108 +++++++++++- test/release-check.test.ts | 2 + .../openclaw-cross-os-release-checks.test.ts | 25 +++ 8 files changed, 302 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8bb30387a8..0b4dda8396c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex. - Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault. - CLI/update: fail package updates when post-update plugin sync fails and refresh legacy npm plugin install records before trusting unchanged artifacts, preventing successful updates from restarting with stale or failed plugin state. Thanks @vincentkoc and @shakkernerd. +- Release/update: reject pre-populated bundled plugin `.openclaw-install-stage` directories, including mixed-case path variants, before package inventory generation so release tarballs cannot ship poisoned runtime-dependency staging debris. Fixes #71752. Thanks @hclsys. - Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28. - Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008. - Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek. diff --git a/package.json b/package.json index 5ccadca226b..843784ef09d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dist/", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", + "!dist/extensions/*/.openclaw-install-stage*/**", "!dist/extensions/*/.openclaw-runtime-deps-*/**", "!dist/extensions/*/.openclaw-runtime-deps-stamp.json", "!dist/extensions/node_modules/**", diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 252bd813d24..e095964a5cb 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -18,6 +18,7 @@ import { createConnection as createNetConnection, createServer as createNetServe import { tmpdir } from "node:os"; import { dirname, join, resolve, win32 as pathWin32 } from "node:path"; import { fileURLToPath } from "node:url"; +import { assertNoBundledRuntimeDepsStagingDebris } from "../src/infra/package-dist-inventory.ts"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; @@ -482,7 +483,8 @@ function isPackagedDistPath(relativePath) { return true; } -async function writePackageDistInventoryForCandidate(params) { +export async function writePackageDistInventoryForCandidate(params) { + await assertNoBundledRuntimeDepsStagingDebris(params.sourceDir); const dryRun = await runCommand( npmCommand(), ["pack", "--dry-run", "--ignore-scripts", "--json"], diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 3a239eb720a..1c6746f9e97 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { + isBundledRuntimeDepsInstallStagePath, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; @@ -585,6 +586,7 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { return [...paths] .filter( (path) => + isBundledRuntimeDepsInstallStagePath(path) || forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) || /(^|\/)\.openclaw-runtime-deps-[^/]+(\/|$)/u.test(path) || path.endsWith("/.openclaw-runtime-deps-stamp.json") || diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 0196c506e43..292077d1883 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -3,9 +3,12 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { + assertNoBundledRuntimeDepsStagingDebris, + collectBundledRuntimeDepsStagingDebrisPaths, collectPackageDistInventoryErrors, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, collectPackageDistInventory, + isBundledRuntimeDepsInstallStagePath, writePackageDistInventory, } from "./package-dist-inventory.js"; @@ -152,6 +155,165 @@ describe("package dist inventory", () => { ]); }); }); + + it("ignores runtime-created install staging dirs during installed dist verification", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-stage-" }, async (packageRoot) => { + const realFile = path.join(packageRoot, "dist", "real-AbC123.js"); + await fs.mkdir(path.dirname(realFile), { recursive: true }); + await fs.writeFile(realFile, "export {};\n", "utf8"); + await writePackageDistInventory(packageRoot); + + const bareStageFile = path.join( + packageRoot, + "dist", + "extensions", + "brave", + ".openclaw-install-stage", + "node_modules", + "typebox", + "build", + "compile", + "code.mjs", + ); + const suffixedStageFile = path.join( + packageRoot, + "dist", + "extensions", + "browser", + ".openclaw-install-stage-AbC123", + "node_modules", + "playwright-core", + "package.json", + ); + await fs.mkdir(path.dirname(bareStageFile), { recursive: true }); + await fs.writeFile(bareStageFile, "// staged\n", "utf8"); + await fs.mkdir(path.dirname(suffixedStageFile), { recursive: true }); + await fs.writeFile(suffixedStageFile, "{}", "utf8"); + + await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([]); + }); + }); + + it("matches install-stage paths case-insensitively across path segments", () => { + expect( + isBundledRuntimeDepsInstallStagePath( + "dist/extensions/brave/.openclaw-install-stage/node_modules/typebox/package.json", + ), + ).toBe(true); + expect( + isBundledRuntimeDepsInstallStagePath( + "dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123/node_modules/playwright-core/package.json", + ), + ).toBe(true); + expect( + isBundledRuntimeDepsInstallStagePath( + "Dist/Extensions/browser/.OpenClaw-Install-Stage/package.json", + ), + ).toBe(true); + expect( + isBundledRuntimeDepsInstallStagePath( + "dist/extensions/browser/.openclaw-runtime-deps-copy-AbC123/package.json", + ), + ).toBe(false); + expect(isBundledRuntimeDepsInstallStagePath("dist/extensions/.openclaw-install-stage")).toBe( + false, + ); + }); + + it("rejects pre-populated install-stage debris at publish time", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-stage-publish-" }, async (packageRoot) => { + const seededStagePackageJson = path.join( + packageRoot, + "dist", + "extensions", + "evil", + ".openclaw-install-stage", + "package.json", + ); + const suffixedSeed = path.join( + packageRoot, + "dist", + "extensions", + "browser", + ".openclaw-install-stage-AbC123", + "node_modules", + "playwright-core", + "package.json", + ); + await fs.mkdir(path.dirname(seededStagePackageJson), { recursive: true }); + await fs.writeFile(seededStagePackageJson, "{}", "utf8"); + await fs.mkdir(path.dirname(suffixedSeed), { recursive: true }); + await fs.writeFile(suffixedSeed, "{}", "utf8"); + + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + "dist/extensions/browser/.openclaw-install-stage-AbC123", + "dist/extensions/evil/.openclaw-install-stage", + ]); + await expect(assertNoBundledRuntimeDepsStagingDebris(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + }); + }); + + it("rejects mixed-case install-stage debris on case-sensitive release builders", async () => { + await withTempDir( + { prefix: "openclaw-dist-inventory-stage-extensions-case-" }, + async (packageRoot) => { + const mixedCaseStage = path.join( + packageRoot, + "dist", + "Extensions", + "evil", + ".OpenClaw-Install-Stage", + "package.json", + ); + await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); + await fs.writeFile(mixedCaseStage, "{}", "utf8"); + + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + "dist/Extensions/evil/.OpenClaw-Install-Stage", + ]); + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + }, + ); + + await withTempDir( + { prefix: "openclaw-dist-inventory-stage-root-case-" }, + async (packageRoot) => { + const mixedCaseStage = path.join( + packageRoot, + "Dist", + "Extensions", + "browser", + ".OPENCLAW-INSTALL-STAGE-AbC123", + "package.json", + ); + await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); + await fs.writeFile(mixedCaseStage, "{}", "utf8"); + + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + "Dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123", + ]); + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected bundled-runtime-deps install staging debris/, + ); + }, + ); + }); + + it("treats a missing dist/extensions tree as no staging debris", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-no-extensions-" }, async (packageRoot) => { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([]); + await expect(assertNoBundledRuntimeDepsStagingDebris(packageRoot)).resolves.toBeUndefined(); + }); + }); + it("fails closed when the inventory is missing", async () => { await withTempDir({ prefix: "openclaw-dist-inventory-missing-" }, async (packageRoot) => { await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 3a4f30b316c..34b893e968c 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -26,16 +26,31 @@ const OMITTED_PRIVATE_QA_DIST_PREFIXES = ["dist/qa-runtime-"]; const OMITTED_DIST_SUBTREE_PATTERNS = [ /^dist\/extensions\/node_modules(?:\/|$)/u, /^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u, - /^dist\/extensions\/[^/]+\/\.openclaw-install-stage(?:-[^/]+)?(?:\/|$)/u, /^dist\/extensions\/[^/]+\/\.openclaw-runtime-deps-[^/]+(?:\/|$)/u, /^dist\/extensions\/qa-matrix(?:\/|$)/u, new RegExp(`^dist/plugin-sdk/extensions/${LEGACY_QA_LAB_DIR}(?:/|$)`, "u"), ] as const; +const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu; function normalizeRelativePath(value: string): string { return value.replace(/\\/g, "/"); } +function isInstallStageDirName(value: string): boolean { + return INSTALL_STAGE_DEBRIS_DIR_PATTERN.test(value); +} + +export function isBundledRuntimeDepsInstallStagePath(relativePath: string): boolean { + const parts = normalizeRelativePath(relativePath).split("/"); + return ( + parts.length >= 4 && + parts[0]?.toLowerCase() === "dist" && + parts[1]?.toLowerCase() === "extensions" && + Boolean(parts[2]) && + isInstallStageDirName(parts[3] ?? "") + ); +} + function isPackagedDistPath(relativePath: string): boolean { if (!relativePath.startsWith("dist/")) { return false; @@ -69,7 +84,10 @@ function isPackagedDistPath(relativePath: string): boolean { } function isOmittedDistSubtree(relativePath: string): boolean { - return OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)); + return ( + isBundledRuntimeDepsInstallStagePath(relativePath) || + OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)) + ); } async function collectRelativeFiles(rootDir: string, baseDir: string): Promise { @@ -114,7 +132,93 @@ export async function collectPackageDistInventory(packageRoot: string): Promise< return await collectRelativeFiles(path.join(packageRoot, "dist"), packageRoot); } +export async function collectBundledRuntimeDepsStagingDebrisPaths( + packageRoot: string, +): Promise { + const distDirs: string[] = []; + try { + const packageRootEntries = await fs.readdir(packageRoot, { withFileTypes: true }); + for (const entry of packageRootEntries) { + if (entry.isDirectory() && entry.name.toLowerCase() === "dist") { + distDirs.push(path.join(packageRoot, entry.name)); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + const debris: string[] = []; + for (const distDir of distDirs) { + let distEntries: import("node:fs").Dirent[]; + try { + distEntries = await fs.readdir(distDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + + for (const distEntry of distEntries) { + if (!distEntry.isDirectory() || distEntry.name.toLowerCase() !== "extensions") { + continue; + } + const extensionsDir = path.join(distDir, distEntry.name); + let extensionEntries: import("node:fs").Dirent[]; + try { + extensionEntries = await fs.readdir(extensionsDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + + for (const extensionEntry of extensionEntries) { + if (!extensionEntry.isDirectory()) { + continue; + } + const extensionPath = path.join(extensionsDir, extensionEntry.name); + let stagingEntries: import("node:fs").Dirent[]; + try { + stagingEntries = await fs.readdir(extensionPath, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + for (const stagingEntry of stagingEntries) { + if (!isInstallStageDirName(stagingEntry.name)) { + continue; + } + debris.push( + normalizeRelativePath( + path.relative(packageRoot, path.join(extensionPath, stagingEntry.name)), + ), + ); + } + } + } + } + return debris.toSorted((left, right) => left.localeCompare(right)); +} + +export async function assertNoBundledRuntimeDepsStagingDebris(packageRoot: string): Promise { + const debris = await collectBundledRuntimeDepsStagingDebrisPaths(packageRoot); + if (debris.length === 0) { + return; + } + throw new Error( + `unexpected bundled-runtime-deps install staging debris in package dist: ${debris.join(", ")}`, + ); +} + export async function writePackageDistInventory(packageRoot: string): Promise { + await assertNoBundledRuntimeDepsStagingDebris(packageRoot); const inventory = [ ...new Set([ ...(await collectPackageDistInventory(packageRoot)), diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 3e9fd02cfc3..da894422940 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -434,10 +434,12 @@ describe("collectForbiddenPackPaths", () => { expect( collectForbiddenPackPaths([ "dist/index.js", + "dist/extensions/browser/.OpenClaw-Install-Stage/package.json", "dist/extensions/codex/.openclaw-runtime-deps-backup-node_modules-old/zod/index.js", "dist/extensions/discord/.openclaw-runtime-deps-stamp.json", ]), ).toEqual([ + "dist/extensions/browser/.OpenClaw-Install-Stage/package.json", "dist/extensions/codex/.openclaw-runtime-deps-backup-node_modules-old/zod/index.js", "dist/extensions/discord/.openclaw-runtime-deps-stamp.json", ]); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 2fb0c6c5c68..cb132ab9bef 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -35,6 +35,7 @@ import { shouldUseManagedGatewayForInstallerRuntime, shouldUseManagedGatewayService, verifyDevUpdateStatus, + writePackageDistInventoryForCandidate, } from "../../scripts/openclaw-cross-os-release-checks.ts"; describe("scripts/openclaw-cross-os-release-checks", () => { @@ -418,6 +419,30 @@ describe("scripts/openclaw-cross-os-release-checks", () => { } }); + it("rejects bundled runtime-deps staging debris before candidate inventory generation", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-stage-debris-")); + try { + mkdirSync( + join(packageRoot, "dist", "Extensions", "demo", ".OpenClaw-Install-Stage", "node_modules"), + { recursive: true }, + ); + writeFileSync( + join(packageRoot, "dist", "Extensions", "demo", ".OpenClaw-Install-Stage", "package.json"), + "{}\n", + "utf8", + ); + + await expect( + writePackageDistInventoryForCandidate({ + sourceDir: packageRoot, + logPath: join(packageRoot, "npm-pack-dry-run.log"), + }), + ).rejects.toThrow("unexpected bundled-runtime-deps install staging debris"); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + it("accepts a git main dev-channel update status payload", () => { expect(() => verifyDevUpdateStatus(