fix(release): reject staged runtime deps in packs

This commit is contained in:
Peter Steinberger
2026-04-26 09:07:53 +01:00
parent 5c0dc93d1e
commit c99d72575e
8 changed files with 302 additions and 3 deletions

View File

@@ -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.

View File

@@ -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/**",

View File

@@ -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"],

View File

@@ -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>): 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") ||

View File

@@ -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 });

View File

@@ -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<string[]> {
@@ -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<string[]> {
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<void> {
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<string[]> {
await assertNoBundledRuntimeDepsStagingDebris(packageRoot);
const inventory = [
...new Set([
...(await collectPackageDistInventory(packageRoot)),

View File

@@ -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",
]);

View File

@@ -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(