fix: harden bundled plugin dependency release checks

This commit is contained in:
Peter Steinberger
2026-04-08 15:14:11 +01:00
parent 0de5db8772
commit edf6b490a6
17 changed files with 474 additions and 358 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify Z.ai vendor code `1311` as billing and `1113` as auth, including long wrapped `1311` payloads, so these errors stop falling through to generic failover handling. (#49552) Thanks @1bcMax.
- npm packaging: mirror bundled Slack, Telegram, Discord, and Feishu channel runtime deps at the root and harden published-install verification so fresh installs fail fast on manifest drift instead of missing-module crashes. (#63065) Thanks @scoootscooob.
- Agents/timeouts: make the LLM idle timeout inherit `agents.defaults.timeoutSeconds` when configured, disable the unconfigured idle watchdog for cron runs, and point idle-timeout errors at `agents.defaults.llm.idleTimeoutSeconds`. Thanks @drvoss.
- npm packaging: derive required root runtime mirrors from bundled plugin manifests and built root chunks, then install packed release tarballs without the repo `node_modules` so release checks catch missing plugin deps before publish.
## 2026.4.8

View File

@@ -16,11 +16,6 @@
},
"extensions": [
"./index.ts"
],
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@aws-sdk/client-bedrock"
]
}
]
}
}

View File

@@ -60,14 +60,6 @@
"bundle": {
"stageRuntimeDependencies": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@buape/carbon",
"@discordjs/opus",
"https-proxy-agent",
"opusscript"
]
},
"release": {
"publishToClawHub": true,
"publishToNpm": true

View File

@@ -51,11 +51,6 @@
"bundle": {
"stageRuntimeDependencies": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@larksuiteoapi/node-sdk"
]
},
"release": {
"publishToClawHub": true,
"publishToNpm": true

View File

@@ -47,13 +47,6 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.9",
"allowInvalidConfigRecovery": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@matrix-org/matrix-sdk-crypto-wasm",
"@matrix-org/matrix-sdk-crypto-nodejs",
"matrix-js-sdk"
]
}
}
}

View File

@@ -34,13 +34,6 @@
},
"bundle": {
"stageRuntimeDependencies": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@slack/bolt",
"@slack/web-api",
"https-proxy-agent"
]
}
}
}

View File

@@ -39,13 +39,6 @@
},
"bundle": {
"stageRuntimeDependencies": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@grammyjs/runner",
"@grammyjs/transformer-throttler",
"grammy"
]
}
}
}

View File

@@ -1311,11 +1311,14 @@
"@aws-sdk/client-bedrock": "3.1024.0",
"@aws-sdk/client-bedrock-runtime": "3.1024.0",
"@aws-sdk/credential-provider-node": "3.972.29",
"@aws/bedrock-token-generator": "^1.1.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^1.2.0",
"@google/genai": "^1.48.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.6",
"@lancedb/lancedb": "^0.27.2",
"@larksuiteoapi/node-sdk": "^1.60.0",
"@line/bot-sdk": "^11.0.0",
"@lydell/node-pty": "1.2.0-beta.10",
@@ -1339,18 +1342,22 @@
"express": "^5.2.1",
"file-type": "22.0.0",
"gaxios": "7.1.4",
"google-auth-library": "^10.6.2",
"grammy": "^1.42.0",
"hono": "4.12.10",
"https-proxy-agent": "^9.0.0",
"ipaddr.js": "^2.3.0",
"jimp": "^1.6.0",
"jiti": "^2.6.1",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"linkedom": "^0.18.12",
"long": "^5.3.2",
"markdown-it": "^14.1.1",
"markdown-it": "14.1.1",
"matrix-js-sdk": "41.3.0-rc.0",
"mpg123-decoder": "^1.0.3",
"node-edge-tts": "^1.2.10",
"openai": "^6.33.0",
"opusscript": "^0.1.1",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.6.205",
@@ -1358,10 +1365,11 @@
"proxy-agent": "^8.0.0",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"silk-wasm": "^3.7.1",
"sqlite-vec": "0.1.9",
"tar": "7.5.13",
"tslog": "^4.10.2",
"undici": "^8.0.2",
"undici": "8.0.2",
"uuid": "^13.0.0",
"ws": "^8.20.0",
"yaml": "^2.8.3",

28
pnpm-lock.yaml generated
View File

@@ -45,12 +45,18 @@ importers:
'@aws-sdk/credential-provider-node':
specifier: 3.972.29
version: 3.972.29
'@aws/bedrock-token-generator':
specifier: ^1.1.0
version: 1.1.0
'@buape/carbon':
specifier: 0.14.0
version: 0.14.0(@discordjs/opus@0.10.0)(hono@4.12.10)(opusscript@0.1.1)
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0
'@google/genai':
specifier: ^1.48.0
version: 1.48.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))
'@grammyjs/runner':
specifier: ^2.0.3
version: 2.0.3(grammy@1.42.0)
@@ -60,6 +66,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.6
version: 1.3.6
'@lancedb/lancedb':
specifier: ^0.27.2
version: 0.27.2(apache-arrow@18.1.0)
'@larksuiteoapi/node-sdk':
specifier: ^1.60.0
version: 1.60.0
@@ -132,6 +141,9 @@ importers:
gaxios:
specifier: 7.1.4
version: 7.1.4
google-auth-library:
specifier: ^10.6.2
version: 10.6.2
grammy:
specifier: ^1.42.0
version: 1.42.0
@@ -144,6 +156,9 @@ importers:
ipaddr.js:
specifier: ^2.3.0
version: 2.3.0
jimp:
specifier: ^1.6.0
version: 1.6.0
jiti:
specifier: ^2.6.1
version: 2.6.1
@@ -160,17 +175,23 @@ importers:
specifier: ^5.3.2
version: 5.3.2
markdown-it:
specifier: ^14.1.1
specifier: 14.1.1
version: 14.1.1
matrix-js-sdk:
specifier: 41.3.0-rc.0
version: 41.3.0-rc.0
mpg123-decoder:
specifier: ^1.0.3
version: 1.0.3
node-edge-tts:
specifier: ^1.2.10
version: 1.2.10
node-llama-cpp:
specifier: 3.18.1
version: 3.18.1(typescript@6.0.2)
openai:
specifier: ^6.33.0
version: 6.33.0(ws@8.20.0)(zod@4.3.6)
opusscript:
specifier: ^0.1.1
version: 0.1.1
@@ -192,6 +213,9 @@ importers:
sharp:
specifier: ^0.34.5
version: 0.34.5
silk-wasm:
specifier: ^3.7.1
version: 3.7.1
sqlite-vec:
specifier: 0.1.9
version: 0.1.9
@@ -202,7 +226,7 @@ importers:
specifier: ^4.10.2
version: 4.10.2
undici:
specifier: ^8.0.2
specifier: 8.0.2
version: 8.0.2
uuid:
specifier: ^13.0.0

View File

@@ -0,0 +1,177 @@
import fs from "node:fs";
import path from "node:path";
const JS_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]);
export function collectRuntimeDependencySpecs(packageJson = {}) {
return new Map(
[
...Object.entries(packageJson.dependencies ?? {}),
...Object.entries(packageJson.optionalDependencies ?? {}),
].filter((entry) => typeof entry[1] === "string" && entry[1].length > 0),
);
}
export function packageNameFromSpecifier(specifier) {
if (
typeof specifier !== "string" ||
specifier.startsWith(".") ||
specifier.startsWith("/") ||
specifier.startsWith("node:") ||
specifier.startsWith("#")
) {
return null;
}
const [first, second] = specifier.split("/");
if (!first) {
return null;
}
return first.startsWith("@") && second ? `${first}/${second}` : first;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function collectPackageJsonPaths(rootDir) {
if (!fs.existsSync(rootDir)) {
return [];
}
return fs
.readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(rootDir, entry.name, "package.json"))
.filter((packageJsonPath) => fs.existsSync(packageJsonPath))
.toSorted((left, right) => left.localeCompare(right));
}
export function collectBundledPluginRuntimeDependencySpecs(bundledPluginsDir) {
const specs = new Map();
for (const packageJsonPath of collectPackageJsonPaths(bundledPluginsDir)) {
const packageJson = readJson(packageJsonPath);
const pluginId = path.basename(path.dirname(packageJsonPath));
for (const [name, spec] of collectRuntimeDependencySpecs(packageJson)) {
const existing = specs.get(name);
if (existing) {
if (existing.spec !== spec) {
existing.conflicts.push({ pluginId, spec });
} else if (!existing.pluginIds.includes(pluginId)) {
existing.pluginIds.push(pluginId);
}
continue;
}
specs.set(name, { conflicts: [], pluginIds: [pluginId], spec });
}
}
return specs;
}
function walkJavaScriptFiles(rootDir) {
const files = [];
if (!fs.existsSync(rootDir)) {
return files;
}
const queue = [rootDir];
while (queue.length > 0) {
const current = queue.shift();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (fullPath.split(path.sep).includes("extensions")) {
continue;
}
queue.push(fullPath);
continue;
}
if (entry.isFile() && JS_EXTENSIONS.has(path.extname(entry.name))) {
files.push(fullPath);
}
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
function extractModuleSpecifiers(source) {
const specifiers = new Set();
const patterns = [
/\bfrom\s*["']([^"']+)["']/g,
/\bimport\s*["']([^"']+)["']/g,
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
if (match[1]) {
specifiers.add(match[1]);
}
}
}
return specifiers;
}
export function collectRootDistBundledRuntimeMirrors(params) {
const distDir = params.distDir;
const bundledSpecs = params.bundledRuntimeDependencySpecs;
const mirrors = new Map();
for (const filePath of walkJavaScriptFiles(distDir)) {
const source = fs.readFileSync(filePath, "utf8");
const relativePath = path.relative(distDir, filePath).replaceAll(path.sep, "/");
for (const specifier of extractModuleSpecifiers(source)) {
const dependencyName = packageNameFromSpecifier(specifier);
if (!dependencyName || !bundledSpecs.has(dependencyName)) {
continue;
}
const bundledSpec = bundledSpecs.get(dependencyName);
const existing = mirrors.get(dependencyName);
if (existing) {
existing.importers.add(relativePath);
continue;
}
mirrors.set(dependencyName, {
importers: new Set([relativePath]),
pluginIds: bundledSpec.pluginIds,
spec: bundledSpec.spec,
});
}
}
return mirrors;
}
export function collectBundledPluginRootRuntimeMirrorErrors(params) {
const rootRuntimeDeps = collectRuntimeDependencySpecs(params.rootPackageJson);
const errors = [];
for (const [dependencyName, record] of params.bundledRuntimeDependencySpecs) {
for (const conflict of record.conflicts) {
errors.push(
`bundled runtime dependency '${dependencyName}' has conflicting plugin specs: ${record.pluginIds.join(", ")} use '${record.spec}', ${conflict.pluginId} uses '${conflict.spec}'.`,
);
}
}
for (const [dependencyName, mirror] of params.requiredRootMirrors) {
const rootSpec = rootRuntimeDeps.get(dependencyName);
const importers = [...mirror.importers].toSorted((left, right) => left.localeCompare(right));
const importerLabel = importers.join(", ");
const pluginLabel = mirror.pluginIds
.toSorted((left, right) => left.localeCompare(right))
.join(", ");
if (typeof rootSpec !== "string" || rootSpec.length === 0) {
errors.push(
`root dist imports bundled plugin runtime dependency '${dependencyName}' from ${importerLabel}; mirror '${dependencyName}: ${mirror.spec}' in root package.json (declared by ${pluginLabel}).`,
);
continue;
}
if (rootSpec !== mirror.spec) {
errors.push(
`root dist imports bundled plugin runtime dependency '${dependencyName}' from ${importerLabel}; root package.json has '${rootSpec}' but plugin manifest declares '${mirror.spec}' (${pluginLabel}).`,
);
}
}
return errors;
}

View File

@@ -15,6 +15,11 @@ import { isAbsolute, join, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts";
import {
collectBundledPluginRootRuntimeMirrorErrors,
collectRootDistBundledRuntimeMirrors,
collectRuntimeDependencySpecs,
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts";
type InstalledPackageJson = {
@@ -26,14 +31,10 @@ type InstalledPackageJson = {
type InstalledBundledExtensionPackageJson = {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
};
type InstalledBundledExtensionManifestRecord = {
id: string;
manifest: InstalledBundledExtensionPackageJson;
path: string;
};
@@ -109,13 +110,6 @@ export function resolveInstalledBinaryPath(prefixDir: string, platform = process
: join(prefixDir, "bin", "openclaw");
}
function collectRuntimeDependencySpecs(packageJson: InstalledPackageJson): Map<string, string> {
return new Map([
...Object.entries(packageJson.dependencies ?? {}),
...Object.entries(packageJson.optionalDependencies ?? {}),
]);
}
function collectExpectedBundledExtensionPackageIds(
sourceExtensionsDir = join(process.cwd(), "extensions"),
): ReadonlySet<string> | null {
@@ -183,6 +177,7 @@ function readBundledExtensionPackageJsons(packageRoot: string): {
}
manifests.push({
id: entry.name,
manifest: JSON.parse(
readFileSync(realPackageJsonPath, "utf8"),
) as InstalledBundledExtensionPackageJson,
@@ -207,54 +202,40 @@ export function collectInstalledMirroredRootDependencyManifestErrors(
}
const rootPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as InstalledPackageJson;
const rootRuntimeDeps = collectRuntimeDependencySpecs(rootPackageJson);
const { manifests, errors } = readBundledExtensionPackageJsons(packageRoot);
const bundledRuntimeDependencySpecs = new Map<
string,
{ conflicts: Array<{ pluginId: string; spec: string }>; pluginIds: string[]; spec: string }
>();
for (const { manifest: extensionPackageJson } of manifests) {
const allowlist = extensionPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
if (allowlist === undefined) {
continue;
}
if (!Array.isArray(allowlist)) {
errors.push(
"installed bundled extension manifest invalid: openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array.",
);
continue;
}
for (const { id, manifest: extensionPackageJson } of manifests) {
const extensionRuntimeDeps = collectRuntimeDependencySpecs(extensionPackageJson);
for (const entry of allowlist) {
if (typeof entry !== "string" || entry.trim().length === 0) {
errors.push(
"installed bundled extension manifest invalid: openclaw.releaseChecks.rootDependencyMirrorAllowlist entries must be non-empty strings.",
);
for (const [dependencyName, spec] of extensionRuntimeDeps) {
const existing = bundledRuntimeDependencySpecs.get(dependencyName);
if (existing) {
if (existing.spec !== spec) {
existing.conflicts.push({ pluginId: id, spec });
} else if (!existing.pluginIds.includes(id)) {
existing.pluginIds.push(id);
}
continue;
}
const extensionSpec = extensionRuntimeDeps.get(entry);
if (!extensionSpec) {
errors.push(
`installed bundled extension manifest invalid: mirrored dependency '${entry}' must be declared in the extension runtime dependencies.`,
);
continue;
}
const rootSpec = rootRuntimeDeps.get(entry);
if (!rootSpec) {
errors.push(
`installed package is missing mirrored root runtime dependency '${entry}' required by a bundled extension.`,
);
continue;
}
if (rootSpec !== extensionSpec) {
errors.push(
`installed package mirrored dependency '${entry}' version mismatch: root '${rootSpec}', extension '${extensionSpec}'.`,
);
}
bundledRuntimeDependencySpecs.set(dependencyName, { conflicts: [], pluginIds: [id], spec });
}
}
const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({
bundledRuntimeDependencySpecs,
distDir: join(packageRoot, "dist"),
});
errors.push(
...collectBundledPluginRootRuntimeMirrorErrors({
bundledRuntimeDependencySpecs,
requiredRootMirrors,
rootPackageJson,
}),
);
return errors;
}
@@ -276,8 +257,12 @@ function resolveGlobalRoot(prefixDir: string, cwd: string): string {
return npmExec(["root", "-g", "--prefix", prefixDir], cwd);
}
export function buildPublishedInstallCommandArgs(prefixDir: string, spec: string): string[] {
return ["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"];
}
function installSpec(prefixDir: string, spec: string, cwd: string): void {
npmExec(["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"], cwd);
npmExec(buildPublishedInstallCommandArgs(prefixDir, spec), cwd);
}
function readInstalledBinaryVersion(prefixDir: string, cwd: string): string {

View File

@@ -1,15 +1,7 @@
#!/usr/bin/env -S node --import tsx
import { execFileSync, execSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
} from "node:fs";
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
@@ -19,11 +11,21 @@ import {
type ExtensionPackageJson as PackageJson,
} from "./lib/bundled-extension-manifest.ts";
import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs";
import {
collectBundledPluginRootRuntimeMirrorErrors,
collectBundledPluginRuntimeDependencySpecs,
collectRootDistBundledRuntimeMirrors,
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs";
import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs";
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
export {
collectBundledPluginRootRuntimeMirrorErrors,
collectRootDistBundledRuntimeMirrors,
packageNameFromSpecifier,
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
type PackFile = { path: string };
type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number };
@@ -78,16 +80,6 @@ function collectBundledExtensions(): BundledExtension[] {
});
}
function collectRuntimeDependencySpecs(packageJson: {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
}): Map<string, string> {
return new Map([
...Object.entries(packageJson.dependencies ?? {}),
...Object.entries(packageJson.optionalDependencies ?? {}),
]);
}
function checkBundledExtensionMetadata() {
const extensions = collectBundledExtensions();
const manifestErrors = collectBundledExtensionManifestErrors(extensions);
@@ -95,11 +87,18 @@ function checkBundledExtensionMetadata() {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const rootRuntimeDeps = collectRuntimeDependencySpecs(rootPackage);
const rootMirrorErrors = collectBundledExtensionRootDependencyMirrorErrors(
extensions,
rootRuntimeDeps,
const bundledRuntimeDependencySpecs = collectBundledPluginRuntimeDependencySpecs(
resolve("extensions"),
);
const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({
bundledRuntimeDependencySpecs,
distDir: resolve("dist"),
});
const rootMirrorErrors = collectBundledPluginRootRuntimeMirrorErrors({
bundledRuntimeDependencySpecs,
requiredRootMirrors,
rootPackageJson: rootPackage,
});
const errors = [...manifestErrors, ...rootMirrorErrors];
if (errors.length > 0) {
console.error("release-check: bundled extension manifest validation failed:");
@@ -110,63 +109,6 @@ function checkBundledExtensionMetadata() {
}
}
export function collectBundledExtensionRootDependencyMirrorErrors(
extensions: BundledExtension[],
rootRuntimeDeps: ReadonlyMap<string, string>,
): string[] {
const errors: string[] = [];
for (const extension of extensions) {
const rawReleaseChecks = extension.packageJson.openclaw?.releaseChecks;
const allowlist = (rawReleaseChecks as { rootDependencyMirrorAllowlist?: unknown } | undefined)
?.rootDependencyMirrorAllowlist;
if (allowlist === undefined) {
continue;
}
if (!Array.isArray(allowlist)) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array`,
);
continue;
}
const extensionRuntimeDeps = collectRuntimeDependencySpecs(extension.packageJson);
for (const entry of allowlist) {
if (typeof entry !== "string" || entry.trim().length === 0) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entries must be non-empty strings`,
);
continue;
}
const extensionSpec = extensionRuntimeDeps.get(entry);
if (!extensionSpec) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '${entry}' must be declared in extension runtime dependencies`,
);
}
const rootSpec = rootRuntimeDeps.get(entry);
if (!rootSpec) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '${entry}' must be mirrored in root runtime dependencies`,
);
}
if (!extensionSpec || !rootSpec) {
continue;
}
if (extensionSpec !== rootSpec) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '${entry}' must match root runtime dependency version (extension '${extensionSpec}', root '${rootSpec}')`,
);
}
}
}
return errors;
}
function runPackDry(): PackResult[] {
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
encoding: "utf8",
@@ -201,32 +143,47 @@ function resolvePackedTarballPath(packDestination: string, results: PackResult[]
return resolve(packDestination, filenames[0]);
}
function linkRootNodeModules(packageRoot: string): void {
const rootNodeModules = resolve("node_modules");
if (!existsSync(rootNodeModules)) {
return;
}
symlinkSync(
rootNodeModules,
join(packageRoot, "node_modules"),
process.platform === "win32" ? "junction" : "dir",
function installPackedTarball(prefixDir: string, tarballPath: string, cwd: string): void {
execFileSync(
"npm",
[
"install",
"-g",
"--prefix",
prefixDir,
"--ignore-scripts",
"--no-audit",
"--no-fund",
tarballPath,
],
{
cwd,
encoding: "utf8",
stdio: "inherit",
},
);
}
function resolveGlobalRoot(prefixDir: string, cwd: string): string {
return execFileSync("npm", ["root", "-g", "--prefix", prefixDir], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function runPackedBundledChannelEntrySmoke(): void {
const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-"));
try {
const packDir = join(tmpRoot, "pack");
const extractDir = join(tmpRoot, "extract");
mkdirSync(packDir);
mkdirSync(extractDir);
const packResults = runPack(packDir);
const tarballPath = resolvePackedTarballPath(packDir, packResults);
execFileSync("tar", ["-xzf", tarballPath, "-C", extractDir], { stdio: "inherit" });
const prefixDir = join(tmpRoot, "prefix");
installPackedTarball(prefixDir, tarballPath, tmpRoot);
const packageRoot = join(extractDir, "package");
linkRootNodeModules(packageRoot);
const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw");
execFileSync(
process.execPath,
[

View File

@@ -19,14 +19,18 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
mirroredRootRuntimeDeps: ["@larksuiteoapi/node-sdk"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "googlechat", minHostVersionBaseline: "2026.3.22" },
{
pluginId: "googlechat",
mirroredRootRuntimeDeps: ["google-auth-library"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "irc", minHostVersionBaseline: "2026.3.22" },
{ pluginId: "line", minHostVersionBaseline: "2026.3.22" },
{ pluginId: "matrix", minHostVersionBaseline: "2026.3.22" },
{ pluginId: "mattermost", minHostVersionBaseline: "2026.3.22" },
{
pluginId: "memory-lancedb",
pluginLocalRuntimeDeps: ["@lancedb/lancedb"],
mirroredRootRuntimeDeps: ["@lancedb/lancedb", "openai"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "msteams", minHostVersionBaseline: "2026.3.22" },
@@ -46,7 +50,8 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
{ pluginId: "voice-call", minHostVersionBaseline: "2026.3.22" },
{
pluginId: "whatsapp",
pluginLocalRuntimeDeps: ["@whiskeysockets/baileys", "jimp"],
pluginLocalRuntimeDeps: ["@whiskeysockets/baileys"],
mirroredRootRuntimeDeps: ["jimp"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "zalo", minHostVersionBaseline: "2026.3.22" },

View File

@@ -66,42 +66,22 @@ function readRootPackageJson(): {
function readMatrixPackageJson(): {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
} {
return JSON.parse(readFileSync(resolve(REPO_ROOT, "extensions/matrix/package.json"), "utf8")) as {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
};
}
function readAmazonBedrockPackageJson(): {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
} {
return JSON.parse(
readFileSync(resolve(REPO_ROOT, "extensions/amazon-bedrock/package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
};
}
@@ -327,15 +307,12 @@ describe("plugin-sdk package contract guardrails", () => {
const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson());
const matrixPackageJson = readMatrixPackageJson();
const matrixRuntimeDeps = collectRuntimeDependencySpecs(matrixPackageJson);
const allowlist = matrixPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
expect(Array.isArray(allowlist)).toBe(true);
const matrixRootMirrorAllowlist = allowlist as string[];
expect(matrixRootMirrorAllowlist).toEqual(
expect.arrayContaining(["@matrix-org/matrix-sdk-crypto-wasm"]),
);
for (const dep of matrixRootMirrorAllowlist) {
for (const dep of [
"@matrix-org/matrix-sdk-crypto-wasm",
"@matrix-org/matrix-sdk-crypto-nodejs",
"matrix-js-sdk",
]) {
expect(rootRuntimeDeps.get(dep)).toBe(matrixRuntimeDeps.get(dep));
}
});
@@ -344,13 +321,8 @@ describe("plugin-sdk package contract guardrails", () => {
const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson());
const bedrockPackageJson = readAmazonBedrockPackageJson();
const bedrockRuntimeDeps = collectRuntimeDependencySpecs(bedrockPackageJson);
const allowlist = bedrockPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
expect(Array.isArray(allowlist)).toBe(true);
const bedrockRootMirrorAllowlist = allowlist as string[];
expect(bedrockRootMirrorAllowlist).toEqual(expect.arrayContaining(["@aws-sdk/client-bedrock"]));
for (const dep of bedrockRootMirrorAllowlist) {
for (const dep of ["@aws-sdk/client-bedrock"]) {
expect(rootRuntimeDeps.get(dep)).toBe(bedrockRuntimeDeps.get(dep));
}
});

View File

@@ -12,9 +12,6 @@ type PackageManifest = {
install?: {
minHostVersion?: string;
};
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
};
@@ -63,12 +60,9 @@ export function describePackageManifestContract(params: PackageManifestContractP
const rootSpec =
rootManifest.dependencies?.[dependencyName] ??
rootManifest.optionalDependencies?.[dependencyName];
const allowlist = pluginManifest.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
expect(pluginSpec).toBeTruthy();
expect(rootSpec).toBe(pluginSpec);
expect(Array.isArray(allowlist)).toBe(true);
expect(allowlist).toContain(dependencyName);
});
}
}

View File

@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import {
buildPublishedInstallCommandArgs,
buildPublishedInstallScenarios,
collectInstalledMirroredRootDependencyManifestErrors,
collectInstalledPackageErrors,
@@ -38,6 +39,23 @@ describe("buildPublishedInstallScenarios", () => {
});
});
describe("buildPublishedInstallCommandArgs", () => {
it("runs lifecycle scripts for published install verification", () => {
const args = buildPublishedInstallCommandArgs("/tmp/openclaw-prefix", "openclaw@2026.4.9");
expect(args).toEqual([
"install",
"-g",
"--prefix",
"/tmp/openclaw-prefix",
"openclaw@2026.4.9",
"--no-fund",
"--no-audit",
]);
expect(args).not.toContain("--ignore-scripts");
});
});
describe("collectInstalledPackageErrors", () => {
it("flags version mismatches and missing runtime sidecars", () => {
const errors = collectInstalledPackageErrors({
@@ -95,7 +113,7 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
it("flags missing mirrored root dependencies required by bundled extensions", () => {
it("flags missing root mirrors for bundled plugin deps imported by root dist", () => {
const packageRoot = makeInstalledPackageRoot();
try {
@@ -107,15 +125,16 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
dependencies: {
"@slack/web-api": "^7.15.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@slack/web-api"],
},
},
});
mkdirSync(join(packageRoot, "dist"), { recursive: true });
writeFileSync(
join(packageRoot, "dist", "probe-Cz2PiFtC.js"),
'import("@slack/web-api");\n',
"utf8",
);
expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([
"installed package is missing mirrored root runtime dependency '@slack/web-api' required by a bundled extension.",
"root dist imports bundled plugin runtime dependency '@slack/web-api' from probe-Cz2PiFtC.js; mirror '@slack/web-api: ^7.15.0' in root package.json (declared by slack).",
]);
} finally {
rmSync(packageRoot, { recursive: true, force: true });
@@ -136,12 +155,13 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
optionalDependencies: {
"@discordjs/opus": "^0.10.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@discordjs/opus"],
},
},
});
mkdirSync(join(packageRoot, "dist"), { recursive: true });
writeFileSync(
join(packageRoot, "dist", "probe-Cz2PiFtC.js"),
'require("@discordjs/opus");\n',
"utf8",
);
expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]);
} finally {
@@ -149,7 +169,7 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
}
});
it("flags mirrored root dependency version mismatches", () => {
it("flags root mirror dependency version mismatches", () => {
const packageRoot = makeInstalledPackageRoot();
try {
@@ -163,15 +183,16 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
dependencies: {
"@slack/web-api": "^7.15.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@slack/web-api"],
},
},
});
mkdirSync(join(packageRoot, "dist"), { recursive: true });
writeFileSync(
join(packageRoot, "dist", "probe-Cz2PiFtC.js"),
'import("@slack/web-api");\n',
"utf8",
);
expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([
"installed package mirrored dependency '@slack/web-api' version mismatch: root '^7.16.0', extension '^7.15.0'.",
"root dist imports bundled plugin runtime dependency '@slack/web-api' from probe-Cz2PiFtC.js; root package.json has '^7.16.0' but plugin manifest declares '^7.15.0' (slack).",
]);
} finally {
rmSync(packageRoot, { recursive: true, force: true });

View File

@@ -1,13 +1,18 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs";
import { listPluginSdkDistArtifacts } from "../scripts/lib/plugin-sdk-entries.mjs";
import {
collectAppcastSparkleVersionErrors,
collectBundledExtensionManifestErrors,
collectBundledExtensionRootDependencyMirrorErrors,
collectBundledPluginRootRuntimeMirrorErrors,
collectRootDistBundledRuntimeMirrors,
collectForbiddenPackPaths,
collectMissingPackPaths,
collectPackUnpackedSizeErrors,
packageNameFromSpecifier,
} from "../scripts/release-check.ts";
import { bundledDistPluginFile, bundledPluginFile } from "./helpers/bundled-plugin-paths.js";
@@ -110,126 +115,132 @@ describe("collectBundledExtensionManifestErrors", () => {
});
});
describe("collectBundledExtensionRootDependencyMirrorErrors", () => {
it("flags a non-array mirror allowlist", () => {
describe("bundled plugin root runtime mirrors", () => {
function makeBundledSpecs() {
return new Map([
["@larksuiteoapi/node-sdk", { conflicts: [], pluginIds: ["feishu"], spec: "^1.60.0" }],
]);
}
it("maps package names from import specifiers", () => {
expect(packageNameFromSpecifier("@larksuiteoapi/node-sdk/subpath")).toBe(
"@larksuiteoapi/node-sdk",
);
expect(packageNameFromSpecifier("grammy/web")).toBe("grammy");
expect(packageNameFromSpecifier("node:fs")).toBeNull();
expect(packageNameFromSpecifier("./local")).toBeNull();
});
it("derives required root mirrors from built root dist imports", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-mirror-"));
try {
const distDir = join(tempRoot, "dist");
mkdirSync(join(distDir, "extensions", "feishu"), { recursive: true });
writeFileSync(
join(distDir, "probe-Cz2PiFtC.js"),
`import("@larksuiteoapi/node-sdk");\nrequire("grammy");\n`,
"utf8",
);
writeFileSync(
join(distDir, "extensions", "feishu", "index.js"),
`import("@larksuiteoapi/node-sdk");\n`,
"utf8",
);
const mirrors = collectRootDistBundledRuntimeMirrors({
bundledRuntimeDependencySpecs: makeBundledSpecs(),
distDir,
});
expect([...mirrors.keys()]).toEqual(["@larksuiteoapi/node-sdk"]);
expect([...mirrors.get("@larksuiteoapi/node-sdk")!.importers]).toEqual(["probe-Cz2PiFtC.js"]);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("flags missing root mirrors for plugin deps imported by root dist", () => {
expect(
collectBundledExtensionRootDependencyMirrorErrors(
[
{
id: "matrix",
packageJson: {
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: true,
},
},
collectBundledPluginRootRuntimeMirrorErrors({
bundledRuntimeDependencySpecs: makeBundledSpecs(),
requiredRootMirrors: new Map([
[
"@larksuiteoapi/node-sdk",
{
importers: new Set(["probe-Cz2PiFtC.js"]),
pluginIds: ["feishu"],
spec: "^1.60.0",
},
},
],
new Map(),
),
],
]),
rootPackageJson: { dependencies: {} },
}),
).toEqual([
"bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array",
"root dist imports bundled plugin runtime dependency '@larksuiteoapi/node-sdk' from probe-Cz2PiFtC.js; mirror '@larksuiteoapi/node-sdk: ^1.60.0' in root package.json (declared by feishu).",
]);
});
it("flags mirror entries missing from extension runtime dependencies", () => {
it("flags root mirror version drift from plugin manifests", () => {
expect(
collectBundledExtensionRootDependencyMirrorErrors(
[
{
id: "matrix",
packageJson: {
dependencies: {
"matrix-js-sdk": "41.2.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"],
},
},
collectBundledPluginRootRuntimeMirrorErrors({
bundledRuntimeDependencySpecs: makeBundledSpecs(),
requiredRootMirrors: new Map([
[
"@larksuiteoapi/node-sdk",
{
importers: new Set(["probe-Cz2PiFtC.js"]),
pluginIds: ["feishu"],
spec: "^1.60.0",
},
},
],
new Map([["@matrix-org/matrix-sdk-crypto-wasm", "18.0.0"]]),
),
],
]),
rootPackageJson: { dependencies: { "@larksuiteoapi/node-sdk": "^1.61.0" } },
}),
).toEqual([
"bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '@matrix-org/matrix-sdk-crypto-wasm' must be declared in extension runtime dependencies",
"root dist imports bundled plugin runtime dependency '@larksuiteoapi/node-sdk' from probe-Cz2PiFtC.js; root package.json has '^1.61.0' but plugin manifest declares '^1.60.0' (feishu).",
]);
});
it("flags mirror entries missing from root runtime dependencies", () => {
it("accepts matching root mirrors for plugin deps imported by root dist", () => {
expect(
collectBundledExtensionRootDependencyMirrorErrors(
[
{
id: "matrix",
packageJson: {
dependencies: {
"@matrix-org/matrix-sdk-crypto-wasm": "18.0.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"],
},
},
collectBundledPluginRootRuntimeMirrorErrors({
bundledRuntimeDependencySpecs: makeBundledSpecs(),
requiredRootMirrors: new Map([
[
"@larksuiteoapi/node-sdk",
{
importers: new Set(["probe-Cz2PiFtC.js"]),
pluginIds: ["feishu"],
spec: "^1.60.0",
},
},
],
new Map(),
),
).toEqual([
"bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '@matrix-org/matrix-sdk-crypto-wasm' must be mirrored in root runtime dependencies",
]);
});
it("flags mirror entries whose root version drifts from the extension", () => {
expect(
collectBundledExtensionRootDependencyMirrorErrors(
[
{
id: "matrix",
packageJson: {
dependencies: {
"@matrix-org/matrix-sdk-crypto-wasm": "18.0.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"],
},
},
},
},
],
new Map([["@matrix-org/matrix-sdk-crypto-wasm", "18.1.0"]]),
),
).toEqual([
"bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '@matrix-org/matrix-sdk-crypto-wasm' must match root runtime dependency version (extension '18.0.0', root '18.1.0')",
]);
});
it("accepts mirror entries declared by both the extension and root package", () => {
expect(
collectBundledExtensionRootDependencyMirrorErrors(
[
{
id: "matrix",
packageJson: {
dependencies: {
"@matrix-org/matrix-sdk-crypto-wasm": "18.0.0",
},
openclaw: {
releaseChecks: {
rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"],
},
},
},
},
],
new Map([["@matrix-org/matrix-sdk-crypto-wasm", "18.0.0"]]),
),
],
]),
rootPackageJson: { dependencies: { "@larksuiteoapi/node-sdk": "^1.60.0" } },
}),
).toEqual([]);
});
it("flags conflicting plugin dependency specs", () => {
expect(
collectBundledPluginRootRuntimeMirrorErrors({
bundledRuntimeDependencySpecs: new Map([
[
"@example/sdk",
{
conflicts: [{ pluginId: "right", spec: "2.0.0" }],
pluginIds: ["left"],
spec: "1.0.0",
},
],
]),
requiredRootMirrors: new Map(),
rootPackageJson: { dependencies: {} },
}),
).toEqual([
"bundled runtime dependency '@example/sdk' has conflicting plugin specs: left use '1.0.0', right uses '2.0.0'.",
]);
});
});
describe("collectForbiddenPackPaths", () => {