diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index c9488d53c069..fa0611bf3356 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -19,6 +19,10 @@ import { pathToFileURL } from "node:url"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; import { ALWAYS_ALLOWED_RUNTIME_DIR_NAMES } from "../src/plugin-sdk/facade-activation-contract.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts"; +import { + WORKER_BUNDLE_ENTRY_PATH, + WORKER_BUNDLE_RSYNC_RECEIVER_PATH, +} from "../src/shared/worker-bundle-hash.js"; import { readBoundedResponseText } from "./lib/bounded-response.mjs"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; import { formatErrorMessage } from "./lib/error-format.mts"; @@ -65,9 +69,15 @@ const PUBLISHED_BUNDLED_RUNTIME_SIDECAR_PATHS = BUNDLED_RUNTIME_SIDECAR_PATHS.fi const NODE_BUILTIN_MODULES = new Set(builtinModules.map((name) => name.replace(/^node:/u, ""))); const MAX_INSTALLED_ROOT_PACKAGE_JSON_BYTES = 1024 * 1024; const MAX_INSTALLED_ROOT_DIST_JS_BYTES = 6 * 1024 * 1024; +const MAX_INSTALLED_WORKER_DEPLOY_DIST_JS_BYTES = 80 * 1024 * 1024; // Keep the dependency scan bounded while allowing headroom for generated root chunks. const MAX_INSTALLED_ROOT_DIST_JS_FILES = 10_000; const ROOT_DIST_JAVASCRIPT_MODULE_FILE_RE = /\.(?:c|m)?js$/u; +// The ~69 MB self-contained worker needs extra headroom, but synchronous read/parse stays bounded. +const SELF_CONTAINED_WORKER_DEPLOY_DIST_PATHS = new Set([ + `worker/${WORKER_BUNDLE_ENTRY_PATH}`, + `worker/${WORKER_BUNDLE_RSYNC_RECEIVER_PATH}`, +]); const OPTIONAL_OR_EXTERNALIZED_RUNTIME_IMPORTS = new Set([ // Optional A2UI markdown renderer. The Canvas host bundle catches the missing // package and falls back when the optional renderer is unavailable. @@ -95,6 +105,10 @@ type DistJavaScriptFileListResult = | { files: string[]; limitExceeded: false } | { files: string[]; limit: number; limitExceeded: true }; +type InstalledRootDistJavaScriptReadResult = + | { error: string; ok: false } + | { ok: true; relativePath: string; source: string }; + type PublishedInstallScenario = { name: string; installSpecs: string[]; @@ -464,7 +478,7 @@ export function collectInstalledPackageErrors(params: { errors.push(...collectInstalledPluginSdkDeclarationErrors(params.packageRoot)); errors.push(...collectInstalledRootDependencyManifestErrors(params.packageRoot)); - return errors; + return [...new Set(errors)]; } export function collectInstalledAlwaysAllowedRuntimeFacadeErrors(packageRoot: string): string[] { @@ -524,13 +538,7 @@ export function normalizeInstalledBinaryVersion(output: string): string { return versionMatch?.[0] ?? trimmed; } -function listDistJavaScriptFiles( - packageRoot: string, - opts: { - maxFiles?: number; - skipRelativePath?: (relativePath: string) => boolean; - } = {}, -): DistJavaScriptFileListResult { +function listInstalledRootDistJavaScriptFiles(packageRoot: string): DistJavaScriptFileListResult { const distDir = join(packageRoot, "dist"); if (!existsSync(distDir)) { return { files: [], limitExceeded: false }; @@ -553,7 +561,7 @@ function listDistJavaScriptFiles( const entryPath = join(currentDir, entry.name); const relativePath = relative(distDir, entryPath).replaceAll("\\", "/"); - if (opts.skipRelativePath?.(relativePath)) { + if (relativePath === "extensions" || relativePath.startsWith("extensions/")) { continue; } if (entry.isDirectory()) { @@ -562,10 +570,10 @@ function listDistJavaScriptFiles( } if (entry.isFile() && ROOT_DIST_JAVASCRIPT_MODULE_FILE_RE.test(entry.name)) { files.push(entryPath); - if (opts.maxFiles !== undefined && files.length > opts.maxFiles) { + if (files.length > MAX_INSTALLED_ROOT_DIST_JS_FILES) { return { files, - limit: opts.maxFiles, + limit: MAX_INSTALLED_ROOT_DIST_JS_FILES, limitExceeded: true, }; } @@ -583,25 +591,43 @@ function formatInstalledDistFileScanLimitError(scope: string, limit: number): st return `installed package ${scope} contains more than ${limit} JavaScript files; refusing to scan unbounded package contents.`; } +function readInstalledRootDistJavaScriptFile( + packageRoot: string, + filePath: string, +): InstalledRootDistJavaScriptReadResult { + const relativePath = relative(join(packageRoot, "dist"), filePath).replaceAll("\\", "/"); + const maxBytes = SELF_CONTAINED_WORKER_DEPLOY_DIST_PATHS.has(relativePath) + ? MAX_INSTALLED_WORKER_DEPLOY_DIST_JS_BYTES + : MAX_INSTALLED_ROOT_DIST_JS_BYTES; + const fileStat = lstatSync(filePath); + if (!fileStat.isFile() || fileStat.size > maxBytes) { + return { + error: `installed package root dist file '${relativePath}' is invalid or exceeds ${maxBytes} bytes.`, + ok: false, + }; + } + return { ok: true, relativePath, source: readFileSync(filePath, "utf8") }; +} + export function collectInstalledContextEngineRuntimeErrors(packageRoot: string): string[] { - const errors: string[] = []; - const distFiles = listDistJavaScriptFiles(packageRoot, { - maxFiles: MAX_INSTALLED_ROOT_DIST_JS_FILES, - }); + const distFiles = listInstalledRootDistJavaScriptFiles(packageRoot); if (distFiles.limitExceeded) { - return [formatInstalledDistFileScanLimitError("dist", distFiles.limit)]; + return [formatInstalledDistFileScanLimitError("root dist", distFiles.limit)]; } + // The legacy marker is a root runtime bundling contract; extension assets are plugin-owned. for (const filePath of distFiles.files) { - const contents = readFileSync(filePath, "utf8"); - if (contents.includes(LEGACY_CONTEXT_ENGINE_UNRESOLVED_RUNTIME_MARKER)) { - errors.push( + const file = readInstalledRootDistJavaScriptFile(packageRoot, filePath); + if (!file.ok) { + return [file.error]; + } + if (file.source.includes(LEGACY_CONTEXT_ENGINE_UNRESOLVED_RUNTIME_MARKER)) { + return [ "installed package includes unresolved legacy context engine runtime loader; rebuild with a bundler-traceable LegacyContextEngine import.", - ); - break; + ]; } } - return errors; + return []; } function collectInstalledPluginSdkDeclarationErrors(packageRoot: string): string[] { @@ -632,14 +658,6 @@ function collectInstalledPluginSdkDeclarationErrors(packageRoot: string): string return errors; } -function listInstalledRootDistJavaScriptFiles(packageRoot: string): DistJavaScriptFileListResult { - return listDistJavaScriptFiles(packageRoot, { - maxFiles: MAX_INSTALLED_ROOT_DIST_JS_FILES, - skipRelativePath: (relativePath) => - relativePath === "extensions" || relativePath.startsWith("extensions/"), - }); -} - type ParsedImportSpecifiersResult = | { ok: true; specifiers: Set } | { ok: false; error: string }; @@ -747,19 +765,14 @@ export function collectInstalledRootDependencyManifestErrors(packageRoot: string collectBundledExtensionRuntimeDependencyOwners(packageRoot); for (const filePath of distFiles.files) { - const fileStat = lstatSync(filePath); - if (!fileStat.isFile() || fileStat.size > MAX_INSTALLED_ROOT_DIST_JS_BYTES) { - const relativePath = relative(join(packageRoot, "dist"), filePath).replaceAll("\\", "/"); - return [ - `installed package root dist file '${relativePath}' is invalid or exceeds ${MAX_INSTALLED_ROOT_DIST_JS_BYTES} bytes.`, - ]; + const file = readInstalledRootDistJavaScriptFile(packageRoot, filePath); + if (!file.ok) { + return [file.error]; } - const source = readFileSync(filePath, "utf8"); - const relativePath = relative(join(packageRoot, "dist"), filePath).replaceAll("\\", "/"); - const parsedSpecifiers = extractJavaScriptImportSpecifiers(source); + const parsedSpecifiers = extractJavaScriptImportSpecifiers(file.source); if (!parsedSpecifiers.ok) { return [ - `installed package root dist file '${relativePath}' could not be parsed for runtime dependency verification: ${parsedSpecifiers.error}.`, + `installed package root dist file '${file.relativePath}' could not be parsed for runtime dependency verification: ${parsedSpecifiers.error}.`, ]; } for (const specifier of parsedSpecifiers.specifiers) { @@ -772,13 +785,13 @@ export function collectInstalledRootDependencyManifestErrors(packageRoot: string isBundledExtensionOwnedRuntimeImport({ dependencyName, ownersByDependency: bundledExtensionRuntimeDependencyOwners, - source, + source: file.source, }) ) { continue; } const importers = missingImporters.get(dependencyName) ?? new Set(); - importers.add(relativePath); + importers.add(file.relativePath); missingImporters.set(dependencyName, importers); } } diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 732f6e704c7a..e6a32331a75d 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { generateKeyPairSync, sign } from "node:crypto"; // OpenClaw npm postpublish tests validate postpublish verification behavior. -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -25,6 +25,10 @@ import { verifyNpmProvenanceAttestation, verifyNpmRegistrySignatures, } from "../scripts/openclaw-npm-postpublish-verify.ts"; +import { + WORKER_BUNDLE_ENTRY_PATH, + WORKER_BUNDLE_RSYNC_RECEIVER_PATH, +} from "../src/shared/worker-bundle-hash.js"; import { withEnv } from "../src/test-utils/env.js"; const INSTALLED_ROOT_DIST_JS_FILE_SCAN_LIMIT = 10_000; @@ -596,6 +600,32 @@ describe("collectInstalledPackageErrors", () => { ); }); + it("rejects an oversized worker before the full verifier reads its contents", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writeFileSync(join(packageRoot, "package.json"), '{"version":"2026.3.23"}\n', "utf8"); + const workerPath = join(packageRoot, "dist", "worker", WORKER_BUNDLE_ENTRY_PATH); + mkdirSync(dirname(workerPath), { recursive: true }); + writeFileSync(workerPath, "/* Failed to load legacy context engine runtime. */\n", "utf8"); + truncateSync(workerPath, 80 * 1024 * 1024 + 1); + + const errors = collectInstalledPackageErrors({ + expectedVersion: "2026.3.23", + installedVersion: "2026.3.23", + packageRoot, + }); + const sizeError = `installed package root dist file 'worker/${WORKER_BUNDLE_ENTRY_PATH}' is invalid or exceeds 83886080 bytes.`; + + expect(errors.filter((error) => error === sizeError)).toEqual([sizeError]); + expect(errors).not.toContain( + "installed package includes unresolved legacy context engine runtime loader; rebuild with a bundler-traceable LegacyContextEngine import.", + ); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + it.each(["ollama", "lmstudio"])( "rejects a missing installed bundled %s provider directory", (providerId) => { @@ -889,6 +919,31 @@ describe("collectInstalledContextEngineRuntimeErrors", () => { } }); + it("ignores extension-owned JavaScript assets", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + const viewerPath = join( + packageRoot, + "dist", + "extensions", + "diffs", + "assets", + "viewer-runtime.js", + ); + mkdirSync(dirname(viewerPath), { recursive: true }); + writeFileSync( + viewerPath, + 'throw new Error("Failed to load legacy context engine runtime.");\n', + "utf8", + ); + + expect(collectInstalledContextEngineRuntimeErrors(packageRoot)).toStrictEqual([]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + it("refuses unbounded packaged dist scans", () => { const packageRoot = makeInstalledPackageRoot(); @@ -896,7 +951,7 @@ describe("collectInstalledContextEngineRuntimeErrors", () => { writeDistJavaScriptFiles(packageRoot, INSTALLED_ROOT_DIST_JS_FILE_SCAN_LIMIT + 1); expect(collectInstalledContextEngineRuntimeErrors(packageRoot)).toEqual([ - `installed package dist contains more than ${INSTALLED_ROOT_DIST_JS_FILE_SCAN_LIMIT} JavaScript files; refusing to scan unbounded package contents.`, + `installed package root dist contains more than ${INSTALLED_ROOT_DIST_JS_FILE_SCAN_LIMIT} JavaScript files; refusing to scan unbounded package contents.`, ]); } finally { rmSync(packageRoot, { recursive: true, force: true }); @@ -1059,7 +1114,7 @@ describe("collectInstalledRootDependencyManifestErrors", () => { } }); - it("flags undeclared imports from mjs and cjs root dist files", () => { + it("flags undeclared imports from nested mjs and direct cjs root dist files", () => { const packageRoot = makeInstalledPackageRoot(); try { @@ -1067,9 +1122,9 @@ describe("collectInstalledRootDependencyManifestErrors", () => { version: "2026.4.22", dependencies: {}, }); - mkdirSync(join(packageRoot, "dist"), { recursive: true }); + mkdirSync(join(packageRoot, "dist", "runtime"), { recursive: true }); writeFileSync( - join(packageRoot, "dist", "esm-entry.mjs"), + join(packageRoot, "dist", "runtime", "esm-entry.mjs"), 'export { value } from "mjs-only";\n', "utf8", ); @@ -1081,7 +1136,7 @@ describe("collectInstalledRootDependencyManifestErrors", () => { expect(collectInstalledRootDependencyManifestErrors(packageRoot)).toEqual([ "installed package root is missing declared runtime dependency 'cjs-only' for dist importers: cjs-entry.cjs. Add it to package.json dependencies/optionalDependencies.", - "installed package root is missing declared runtime dependency 'mjs-only' for dist importers: esm-entry.mjs. Add it to package.json dependencies/optionalDependencies.", + "installed package root is missing declared runtime dependency 'mjs-only' for dist importers: runtime/esm-entry.mjs. Add it to package.json dependencies/optionalDependencies.", ]); } finally { rmSync(packageRoot, { recursive: true, force: true }); @@ -1155,7 +1210,40 @@ describe("collectInstalledRootDependencyManifestErrors", () => { } }); - it("refuses oversized root dist files", () => { + it.each([ + { + expected: [ + "installed package root dist file 'oversized.js' is invalid or exceeds 6291456 bytes.", + ], + name: "rejects oversized direct dist files", + relativePath: "oversized.js", + }, + { + expected: [ + "installed package root dist file 'runtime/oversized.js' is invalid or exceeds 6291456 bytes.", + ], + name: "rejects oversized arbitrary nested dist files", + relativePath: "runtime/oversized.js", + }, + { + expected: [], + name: "accepts the oversized worker deploy entrypoint", + relativePath: `worker/${WORKER_BUNDLE_ENTRY_PATH}`, + }, + { + expected: [], + name: "accepts the oversized worker rsync receiver", + relativePath: `worker/${WORKER_BUNDLE_RSYNC_RECEIVER_PATH}`, + }, + { + expected: [ + `installed package root dist file 'worker/${WORKER_BUNDLE_ENTRY_PATH}' is invalid or exceeds 83886080 bytes.`, + ], + name: "rejects the worker deploy entrypoint above its dedicated parser bound", + relativePath: `worker/${WORKER_BUNDLE_ENTRY_PATH}`, + sparseSize: 80 * 1024 * 1024 + 1, + }, + ])("$name", ({ expected, relativePath, sparseSize }) => { const packageRoot = makeInstalledPackageRoot(); try { @@ -1163,16 +1251,16 @@ describe("collectInstalledRootDependencyManifestErrors", () => { version: "2026.4.22", dependencies: {}, }); - mkdirSync(join(packageRoot, "dist"), { recursive: true }); - writeFileSync( - join(packageRoot, "dist", "oversized.js"), - "x".repeat(6 * 1024 * 1024 + 1), - "utf8", - ); + const filePath = join(packageRoot, "dist", relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + if (sparseSize) { + writeFileSync(filePath, "/*", "utf8"); + truncateSync(filePath, sparseSize); + } else { + writeFileSync(filePath, `/* ${"x".repeat(6 * 1024 * 1024)} */\n`, "utf8"); + } - expect(collectInstalledRootDependencyManifestErrors(packageRoot)).toEqual([ - "installed package root dist file 'oversized.js' is invalid or exceeds 6291456 bytes.", - ]); + expect(collectInstalledRootDependencyManifestErrors(packageRoot)).toEqual(expected); } finally { rmSync(packageRoot, { recursive: true, force: true }); }