mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(release): reject staged runtime deps in packs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user