From 2e8f91c36e5a4f438e19bb96b628cbccd465766f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 14:06:46 +0100 Subject: [PATCH] fix(release): verify package entrypoint imports --- scripts/check-openclaw-package-tarball.mjs | 83 ++++++++++++++++++- src/infra/package-dist-inventory.ts | 69 +++++++++++++++ src/infra/update-global.test.ts | 20 +++++ src/infra/update-global.ts | 5 +- .../check-openclaw-package-tarball.test.ts | 30 +++++++ 5 files changed, 203 insertions(+), 4 deletions(-) diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index 55516ba0741..bb1fc38e0f7 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -4,6 +4,8 @@ // prebuilt package artifact with dist inventory, not a source checkout. import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs"; function usage() { @@ -39,6 +41,17 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, "")); const entrySet = new Set(normalized); const errors = []; const warnings = []; +const unsafeEntries = normalized.filter( + (entry) => entry.startsWith("/") || entry.split("/").includes(".."), +); +const DIST_JS_IMPORT_SPECIFIER_PATTERN = + /\b(?:import|export)\s+(?:(?:[^'"()]*?\s+from\s+)|)["'](?[^"']+)["']|\bimport\s*\(\s*["'](?[^"']+)["']\s*\)/gu; +const DIST_IMPORT_REFERENCE_ENTRYPOINTS = [ + "dist/entry.js", + "dist/cli/run-main.js", + "dist/index.js", + "dist/index.mjs", +]; const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 }; const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 }; const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS); @@ -117,10 +130,73 @@ function readTarEntry(entryPath) { return ""; } -for (const entry of normalized) { - if (entry.startsWith("/") || entry.split("/").includes("..")) { - errors.push(`unsafe tar entry: ${entry}`); +function isRelativeModuleSpecifier(value) { + return value.startsWith("./") || value.startsWith("../"); +} + +function normalizeModuleSpecifierTarget(value) { + return value.split(/[?#]/u, 1)[0] ?? value; +} + +function normalizeTarPath(value) { + return value.replace(/\\/gu, "/"); +} + +function resolveTarImportTarget(importer, specifier) { + const normalizedSpecifier = normalizeModuleSpecifierTarget(specifier); + const base = normalizeTarPath( + new URL(normalizedSpecifier, `file:///${importer}`).pathname.replace(/^\//u, ""), + ); + const candidates = [ + base, + `${base}.js`, + `${base}.mjs`, + `${base}.cjs`, + `${base}/index.js`, + `${base}/index.mjs`, + `${base}/index.cjs`, + ]; + return candidates.find((candidate) => entrySet.has(candidate)) ?? null; +} + +function collectTarImportReferenceErrors() { + if (unsafeEntries.length > 0) { + return []; } + const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-check-")); + const importErrors = []; + try { + const extract = spawnSync("tar", ["-xzf", tarball, "-C", extractRoot], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (extract.status !== 0) { + return [ + `tar extraction failed for import reference check: ${extract.stderr || extract.status}`, + ]; + } + + for (const entry of DIST_IMPORT_REFERENCE_ENTRYPOINTS.filter((entry) => entrySet.has(entry))) { + const source = fs.readFileSync(path.join(extractRoot, "package", entry), "utf8"); + for (const match of source.matchAll(DIST_JS_IMPORT_SPECIFIER_PATTERN)) { + const specifier = match.groups?.staticSpecifier ?? match.groups?.dynamicSpecifier ?? ""; + if (!isRelativeModuleSpecifier(specifier)) { + continue; + } + if (resolveTarImportTarget(entry, specifier)) { + continue; + } + importErrors.push(`missing packaged dist import target ${specifier} from ${entry}`); + } + } + } finally { + fs.rmSync(extractRoot, { recursive: true, force: true }); + } + return importErrors.toSorted((left, right) => left.localeCompare(right)); +} + +for (const entry of unsafeEntries) { + errors.push(`unsafe tar entry: ${entry}`); } if (!entrySet.has("package.json")) { @@ -182,6 +258,7 @@ if (entrySet.has("dist/postinstall-inventory.json")) { ); } } +errors.push(...collectTarImportReferenceErrors()); if (errors.length > 0) { fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`); diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 0d2ae6d2d36..7747c3d27b9 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -262,6 +262,75 @@ export async function readPackageDistInventoryIfPresent( } } +const DIST_JS_IMPORT_SPECIFIER_PATTERN = + /\b(?:import|export)\s+(?:(?:[^'"()]*?\s+from\s+)|)["'](?[^"']+)["']|\bimport\s*\(\s*["'](?[^"']+)["']\s*\)/gu; +const PACKAGE_DIST_IMPORT_REFERENCE_ENTRYPOINTS = [ + "dist/entry.js", + "dist/cli/run-main.js", + "dist/index.js", + "dist/index.mjs", +] as const; + +function isRelativeModuleSpecifier(value: string): boolean { + return value.startsWith("./") || value.startsWith("../"); +} + +function normalizeModuleSpecifierTarget(value: string): string { + return value.split(/[?#]/u, 1)[0] ?? value; +} + +function resolveDistImportTarget(importer: string, specifier: string, actualFiles: Set) { + const normalizedSpecifier = normalizeModuleSpecifierTarget(specifier); + const base = normalizeRelativePath( + path.posix.normalize(path.posix.join(path.posix.dirname(importer), normalizedSpecifier)), + ); + const candidates = [ + base, + `${base}.js`, + `${base}.mjs`, + `${base}.cjs`, + `${base}/index.js`, + `${base}/index.mjs`, + `${base}/index.cjs`, + ]; + return candidates.find((candidate) => actualFiles.has(candidate)) ?? null; +} + +export async function collectPackageDistImportReferenceErrors( + packageRoot: string, +): Promise { + const actualFiles = new Set(await collectPackageDistInventory(packageRoot)); + const jsFiles = PACKAGE_DIST_IMPORT_REFERENCE_ENTRYPOINTS.filter((relativePath) => + actualFiles.has(relativePath), + ); + const errors: string[] = []; + + await Promise.all( + jsFiles.map(async (relativePath) => { + let source: string; + try { + source = await fs.readFile(path.join(packageRoot, relativePath), "utf8"); + } catch { + errors.push(`unable to read packaged dist file ${relativePath}`); + return; + } + + for (const match of source.matchAll(DIST_JS_IMPORT_SPECIFIER_PATTERN)) { + const specifier = match.groups?.staticSpecifier ?? match.groups?.dynamicSpecifier ?? ""; + if (!isRelativeModuleSpecifier(specifier)) { + continue; + } + if (resolveDistImportTarget(relativePath, specifier, actualFiles)) { + continue; + } + errors.push(`missing packaged dist import target ${specifier} from ${relativePath}`); + } + }), + ); + + return errors.toSorted((left, right) => left.localeCompare(right)); +} + export async function collectPackageDistInventoryErrors(packageRoot: string): Promise { const expectedFiles = await readPackageDistInventoryIfPresent(packageRoot); if (expectedFiles === null) { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index f64c8bd1f76..677fe284551 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -457,6 +457,26 @@ describe("update global helpers", () => { }); }); + it("checks installed dist import references during global verify", async () => { + await withTempDir({ prefix: "openclaw-update-global-imports-" }, async (packageRoot) => { + await writeGlobalPackageJson(packageRoot, "2026.4.27"); + const runMain = path.join(packageRoot, "dist", "cli", "run-main.js"); + await fs.mkdir(path.dirname(runMain), { recursive: true }); + await fs.writeFile(runMain, 'await import("../memory-state-CcqRgDZU.js");\n', "utf8"); + await writePackageDistInventory(packageRoot); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + "missing packaged dist import target ../memory-state-CcqRgDZU.js from dist/cli/run-main.js", + ); + + const chunk = path.join(packageRoot, "dist", "memory-state-CcqRgDZU.js"); + await fs.writeFile(chunk, "export {};\n", "utf8"); + await writePackageDistInventory(packageRoot); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toEqual([]); + }); + }); + it("ignores bundled plugin install stages during installed dist verification", async () => { await withTempDir({ prefix: "openclaw-update-global-plugin-stage-" }, async (packageRoot) => { await writeGlobalPackageJson(packageRoot); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index efdaaa41207..fb969bbb01e 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -7,6 +7,7 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { pathExists } from "../utils.js"; import { collectPackageDistInventory, + collectPackageDistImportReferenceErrors, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, readPackageDistInventoryIfPresent, } from "./package-dist-inventory.js"; @@ -154,15 +155,17 @@ async function collectInstalledPackageDistErrors(params: { missingMessage: (relativePath) => `missing packaged dist file ${relativePath}`, unexpectedMessage: (relativePath) => `unexpected packaged dist file ${relativePath}`, }); + const importErrors = await collectPackageDistImportReferenceErrors(params.packageRoot); const inventorySet = new Set(inventoryFiles); const supplementalCriticalPaths = criticalPaths.filter( (relativePath) => !inventorySet.has(relativePath), ); if (supplementalCriticalPaths.length === 0) { - return inventoryErrors; + return [...inventoryErrors, ...importErrors]; } return [ ...inventoryErrors, + ...importErrors, ...(await collectInstalledPathErrors({ packageRoot: params.packageRoot, expectedFiles: supplementalCriticalPaths, diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index fa83805980d..4aac37c70eb 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -85,6 +85,36 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("rejects packaged JS imports that point at missing dist chunks", () => { + withTarball( + ["dist/cli/run-main.js"], + { "dist/cli/run-main.js": 'await import("../memory-state-CcqRgDZU.js");\n' }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "missing packaged dist import target ../memory-state-CcqRgDZU.js from dist/cli/run-main.js", + ); + }, + ); + }); + + it("accepts packaged JS imports that resolve to shipped dist chunks", () => { + withTarball( + ["dist/cli/run-main.js", "dist/memory-state-DwGdReW4.js"], + { + "dist/cli/run-main.js": 'await import("../memory-state-DwGdReW4.js");\n', + "dist/memory-state-DwGdReW4.js": "export {};\n", + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + }, + ); + }); + it("rejects local build metadata entries in package tarballs", () => { withTarball( ["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS],