From d5a374070794cb94cfe460b46b4ed957c1fa1d3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 14:05:41 -0700 Subject: [PATCH] fix(package): prevent installs from deleting required runtime modules (#113821) * fix(package): preserve imported runtime chunks after install * fix(package): isolate public build from private QA * test(package): prove installed postinstall dependency graph --- .github/workflows/ci.yml | 5 +- Dockerfile | 1 + package.json | 1 + scripts/docker/cleanup-smoke/Dockerfile | 1 + scripts/lib/guard-inventory-utils.d.mts | 12 +- scripts/lib/guard-inventory-utils.mjs | 35 ++++- scripts/lib/package-dist-imports.mjs | 126 ++++-------------- scripts/package-openclaw-for-docker.mjs | 2 + scripts/release-check.ts | 1 + src/dockerfile.test.ts | 5 + .../package-openclaw-for-docker.e2e.test.ts | 6 + test/release-check.test.ts | 2 + .../check-openclaw-package-tarball.test.ts | 98 ++++++++++++++ .../check-package-dist-imports.test.ts | 63 +++++++++ test/scripts/ci-workflow-guards.test.ts | 5 +- .../postinstall-bundled-plugins.test.ts | 44 ++++++ 16 files changed, 299 insertions(+), 108 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b59c3d06b935..b10ff2bc3306 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1683,10 +1683,7 @@ jobs: OPENCLAW_BUILD_PRIVATE_QA: "1" NODE_OPTIONS: --max-old-space-size=8192 run: | - node scripts/build-all.mjs qaRuntime - pnpm ui:build package_args=( - --skip-build --output-dir .artifacts/qa-e2e/smoke-ci-package --output-name openclaw-current.tgz ) @@ -1694,6 +1691,8 @@ jobs: package_args=(--allow-unreleased-changelog "${package_args[@]}") fi node scripts/package-openclaw-for-docker.mjs "${package_args[@]}" + node scripts/build-all.mjs qaRuntime + pnpm ui:build - name: Run smoke profile part env: diff --git a/Dockerfile b/Dockerfile index cc56d520bf6e..a66520eb736c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,7 @@ COPY openclaw.mjs ./ COPY ui/package.json ./ui/package.json COPY patches ./patches COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs scripts/prepare-git-hooks.mjs ./scripts/ +COPY scripts/lib/guard-inventory-utils.mjs ./scripts/lib/guard-inventory-utils.mjs COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs COPY --from=workspace-deps /out/packages/ ./packages/ diff --git a/package.json b/package.json index 3b9a90d91453..9fa62ff458e2 100644 --- a/package.json +++ b/package.json @@ -347,6 +347,7 @@ "scripts/lib/official-external-channel-catalog.json", "scripts/lib/official-external-plugin-catalog.json", "scripts/lib/official-external-provider-catalog.json", + "scripts/lib/guard-inventory-utils.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/lib/recommended-tool-installs.json", "scripts/postinstall-bundled-plugins.mjs", diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 2eae361aec0d..3a959e8a4eee 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -20,6 +20,7 @@ COPY packages ./packages COPY extensions ./extensions COPY patches ./patches COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/prepare-git-hooks.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/ +COPY scripts/lib/guard-inventory-utils.mjs ./scripts/lib/guard-inventory-utils.mjs COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ corepack enable \ diff --git a/scripts/lib/guard-inventory-utils.d.mts b/scripts/lib/guard-inventory-utils.d.mts index 572fe0170a13..eabf3858238d 100644 --- a/scripts/lib/guard-inventory-utils.d.mts +++ b/scripts/lib/guard-inventory-utils.d.mts @@ -6,8 +6,16 @@ export function resolveRepoSpecifier( specifier: unknown, importerFile: unknown, ): string | null; -/** Visit static and dynamic module specifiers in a parsed TypeScript source file. */ -export function visitModuleSpecifiers(ts: unknown, sourceFile: unknown, visit: unknown): void; +/** Visit module specifiers, optionally including packaged-runtime dependencies. */ +export function visitModuleSpecifiers( + ts: unknown, + sourceFile: unknown, + visit: unknown, + options?: { + includeCommonJs?: boolean; + includeImportMetaUrl?: boolean; + }, +): void; /** Diff expected and actual inventory entries using JSON identity. */ export function diffInventoryEntries( expected: unknown, diff --git a/scripts/lib/guard-inventory-utils.mjs b/scripts/lib/guard-inventory-utils.mjs index 25b4fd9cf335..08acf6628d5d 100644 --- a/scripts/lib/guard-inventory-utils.mjs +++ b/scripts/lib/guard-inventory-utils.mjs @@ -22,7 +22,7 @@ export function resolveRepoSpecifier(repoRoot, specifier, importerFile) { } /** Visit static and dynamic module specifiers in a parsed TypeScript source file. */ -export function visitModuleSpecifiers(ts, sourceFile, visit) { +export function visitModuleSpecifiers(ts, sourceFile, visit, options = {}) { function walk(node) { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { visit({ @@ -54,6 +54,39 @@ export function visitModuleSpecifiers(ts, sourceFile, visit) { specifier: node.arguments[0].text, specifierNode: node.arguments[0], }); + } else if ( + options.includeCommonJs && + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "require" && + node.arguments.length === 1 && + ts.isStringLiteralLike(node.arguments[0]) + ) { + visit({ + kind: "commonjs-require", + node, + specifier: node.arguments[0].text, + specifierNode: node.arguments[0], + }); + } else if ( + options.includeImportMetaUrl && + ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "URL" && + node.arguments?.length >= 2 && + ts.isStringLiteralLike(node.arguments[0]) && + ts.isPropertyAccessExpression(node.arguments[1]) && + node.arguments[1].name.text === "url" && + ts.isMetaProperty(node.arguments[1].expression) && + node.arguments[1].expression.keywordToken === ts.SyntaxKind.ImportKeyword && + node.arguments[1].expression.name.text === "meta" + ) { + visit({ + kind: "import-meta-url", + node, + specifier: node.arguments[0].text, + specifierNode: node.arguments[0], + }); } ts.forEachChild(node, walk); diff --git a/scripts/lib/package-dist-imports.mjs b/scripts/lib/package-dist-imports.mjs index b8caf4d02dac..80ffe22feb19 100644 --- a/scripts/lib/package-dist-imports.mjs +++ b/scripts/lib/package-dist-imports.mjs @@ -1,5 +1,7 @@ // Scans packaged dist JavaScript for relative imports and missing closure entries. import path from "node:path"; +import ts from "typescript"; +import { visitModuleSpecifiers } from "./guard-inventory-utils.mjs"; const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u; @@ -26,108 +28,30 @@ function resolveDistImportPath(importerPath, specifier) { return path.posix.normalize(path.posix.join(path.posix.dirname(importerPath), stripped)); } -function findStatementStart(source, index) { - return ( - Math.max( - source.lastIndexOf(";", index), - source.lastIndexOf("{", index), - source.lastIndexOf("}", index), - source.lastIndexOf("\n", index), - source.lastIndexOf("\r", index), - ) + 1 - ); -} - -function isImportSpecifierContext(source, index) { - const dynamicPrefix = source.slice(Math.max(0, index - 32), index); - if (/\bimport\s*\(\s*$/u.test(dynamicPrefix)) { - return true; - } - const statementPrefix = source.slice(findStatementStart(source, index), index).trimStart(); - return ( - /^(?:import|export)\b[\s\S]*\bfrom\s*$/u.test(statementPrefix) || - /^import\s*$/u.test(statementPrefix) - ); -} - -function isRequireSpecifierContext(source, index) { - const prefix = source.slice(Math.max(0, index - 32), index); - return /\brequire\s*\(\s*$/u.test(prefix); -} - -function isImportMetaUrlContext(source, quoteStart, quoteEnd) { - const prefix = source.slice(Math.max(0, quoteStart - 32), quoteStart); - if (!/\bnew\s+URL\s*\(\s*$/u.test(prefix)) { - return false; - } - const suffix = source.slice(quoteEnd + 1, quoteEnd + 96); - return /^\s*,\s*import\.meta\.url\s*,?\s*\)/u.test(suffix); -} - -function collectImportSpecifiers(source) { +function collectImportSpecifiers(source, importerPath) { const specifiers = []; - let inBlockComment = false; - let inLineComment = false; - for (let index = 0; index < source.length; index += 1) { - if (inBlockComment) { - if (source[index] === "*" && source[index + 1] === "/") { - inBlockComment = false; - index += 1; + const sourceFile = ts.createSourceFile( + importerPath, + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS, + ); + visitModuleSpecifiers( + ts, + sourceFile, + ({ kind, specifier }) => { + if ( + specifier.startsWith(".") && + (kind !== "import-meta-url" || + (hasJavaScriptFileExtension(specifier) && + resolveDistImportPath(importerPath, specifier)?.startsWith("dist/"))) + ) { + specifiers.push(specifier); } - continue; - } - if (inLineComment) { - if (source[index] === "\n" || source[index] === "\r") { - inLineComment = false; - } - continue; - } - if (source[index] === "/" && source[index + 1] === "*") { - inBlockComment = true; - index += 1; - continue; - } - if (source[index] === "/" && source[index + 1] === "/") { - inLineComment = true; - index += 1; - continue; - } - - const quote = source[index]; - if (quote !== '"' && quote !== "'") { - continue; - } - - let cursor = index + 1; - let value = ""; - while (cursor < source.length) { - const char = source[cursor]; - if (char === "\\") { - value += source.slice(cursor, cursor + 2); - cursor += 2; - continue; - } - if (char === quote) { - break; - } - value += char; - cursor += 1; - } - if (cursor >= source.length) { - break; - } - - if (value.startsWith(".")) { - const isDistDependency = - isImportSpecifierContext(source, index) || - isRequireSpecifierContext(source, index) || - (isImportMetaUrlContext(source, index, cursor) && hasJavaScriptFileExtension(value)); - if (isDistDependency) { - specifiers.push(value); - } - } - index = cursor; - } + }, + { includeCommonJs: true, includeImportMetaUrl: true }, + ); return specifiers; } @@ -157,7 +81,7 @@ export function collectPackageDistImports(params) { continue; } const source = params.readText(importerPath); - for (const specifier of collectImportSpecifiers(source)) { + for (const specifier of collectImportSpecifiers(source, importerPath)) { const importedPath = resolveDistImportPath(importerPath, specifier); if (!importedPath) { continue; diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index cae3d5b99690..6b362ba6722f 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -30,6 +30,8 @@ const PACKAGE_BUILD_PLUGIN_SELECTION_ENV_NAMES = [ "OPENCLAW_EXTENSIONS", "OPENCLAW_DOCKER_BUILD_EXTENSIONS", DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV, + // Public package builds must not inherit a smoke lane's private QA entrypoints. + "OPENCLAW_BUILD_PRIVATE_QA", ]; const SIGNAL_EXIT_CODES = { SIGHUP: 129, diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 6fbc11cc74bd..b6ed1a347e4e 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -108,6 +108,7 @@ const requiredPathGroups = [ "scripts/lib/official-external-plugin-catalog.json", "scripts/lib/official-external-provider-catalog.json", "scripts/lib/recommended-tool-installs.json", + "scripts/lib/guard-inventory-utils.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/agents/compaction-planning.worker.js", diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 49c835ff339c..d9becd60f577 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -164,6 +164,9 @@ describe("Dockerfile", () => { const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); const postinstallIndex = dockerfile.indexOf("COPY scripts/postinstall-bundled-plugins.mjs"); const prepareIndex = dockerfile.indexOf("scripts/prepare-git-hooks.mjs"); + const importGrammarIndex = dockerfile.indexOf( + "COPY scripts/lib/guard-inventory-utils.mjs ./scripts/lib/guard-inventory-utils.mjs", + ); const distImportHelperIndex = dockerfile.indexOf( "COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs", ); @@ -176,6 +179,7 @@ describe("Dockerfile", () => { expect(postinstallIndex).toBeGreaterThan(-1); expect(prepareIndex).toBeGreaterThan(-1); + expect(importGrammarIndex).toBeGreaterThan(-1); expect(distImportHelperIndex).toBeGreaterThan(-1); expect(packageManifestIndex).toBeGreaterThan(-1); expect(extensionManifestIndex).toBeGreaterThan(-1); @@ -190,6 +194,7 @@ describe("Dockerfile", () => { ); expect(postinstallIndex).toBeLessThan(installIndex); expect(prepareIndex).toBeLessThan(installIndex); + expect(importGrammarIndex).toBeLessThan(installIndex); expect(distImportHelperIndex).toBeLessThan(installIndex); expect(packageManifestIndex).toBeLessThan(installIndex); expect(extensionManifestIndex).toBeLessThan(installIndex); diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 9592625ee34c..c7fb7ba530a5 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -347,6 +347,7 @@ describe("package-openclaw-for-docker", () => { packageExtensions: string | undefined; dockerBuildExtensions: string | undefined; internalDockerBuildPluginIds: string | undefined; + privateQa: string | undefined; skipDts: string | undefined; timeoutMs: number | undefined; }> = []; @@ -355,11 +356,13 @@ describe("package-openclaw-for-docker", () => { const previousPackageExtensions = process.env.OPENCLAW_EXTENSIONS; const previousDockerBuildExtensions = process.env.OPENCLAW_DOCKER_BUILD_EXTENSIONS; const previousInternalPluginIds = process.env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]; + const previousPrivateQa = process.env.OPENCLAW_BUILD_PRIVATE_QA; process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS = "1234"; process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD = "1"; process.env.OPENCLAW_EXTENSIONS = "clickclack"; process.env.OPENCLAW_DOCKER_BUILD_EXTENSIONS = "slack"; process.env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV] = "msteams"; + process.env.OPENCLAW_BUILD_PRIVATE_QA = "1"; try { await buildPackageArtifacts("/repo", { @@ -377,6 +380,7 @@ describe("package-openclaw-for-docker", () => { packageExtensions: options.env?.OPENCLAW_EXTENSIONS, dockerBuildExtensions: options.env?.OPENCLAW_DOCKER_BUILD_EXTENSIONS, internalDockerBuildPluginIds: options.env?.[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV], + privateQa: options.env?.OPENCLAW_BUILD_PRIVATE_QA, skipDts: options.env?.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD, timeoutMs: options.timeoutMs, }); @@ -397,6 +401,7 @@ describe("package-openclaw-for-docker", () => { ["OPENCLAW_EXTENSIONS", previousPackageExtensions], ["OPENCLAW_DOCKER_BUILD_EXTENSIONS", previousDockerBuildExtensions], [DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV, previousInternalPluginIds], + ["OPENCLAW_BUILD_PRIVATE_QA", previousPrivateQa], ] as const) { if (previousValue === undefined) { delete process.env[envName]; @@ -415,6 +420,7 @@ describe("package-openclaw-for-docker", () => { internalDockerBuildPluginIds: undefined, noPnpm: "1", packageExtensions: undefined, + privateQa: undefined, skipDts: "0", timeoutMs: 1234, }, diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 1250fe161a1e..a903367e0abb 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -716,6 +716,7 @@ describe("collectMissingPackPaths", () => { "scripts/lib/official-external-plugin-catalog.json", "scripts/lib/official-external-provider-catalog.json", "scripts/lib/recommended-tool-installs.json", + "scripts/lib/guard-inventory-utils.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/agents/compaction-planning.worker.js", @@ -756,6 +757,7 @@ describe("collectMissingPackPaths", () => { "scripts/lib/official-external-plugin-catalog.json", "scripts/lib/official-external-provider-catalog.json", "scripts/lib/recommended-tool-installs.json", + "scripts/lib/guard-inventory-utils.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/agents/compaction-planning.worker.js", diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 1b78c38431c2..67d05c3a2f37 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -357,6 +357,66 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("rejects leaked private QA Docker chunks that import an omitted QA runtime", () => { + withTarball( + ["dist/docker-runtime-BVdgRgxA.js"], + { + "dist/docker-runtime-BVdgRgxA.js": + 'import { createQaDockerRuntime } from "./qa-runtime-Bi1S3plf.js";\n' + + "export { createQaDockerRuntime };\n", + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "dist/docker-runtime-BVdgRgxA.js imports missing dist/qa-runtime-Bi1S3plf.js", + ); + }, + ); + }); + + it.each([ + { + name: "named imports", + source: 'import { value } from "./missing.js";\n', + }, + { + name: "multiline named imports", + source: 'import {\n value,\n} from "./missing.js";\n', + }, + { + name: "named re-exports", + source: 'export { value } from "./missing.js";\n', + }, + ])("rejects missing packaged chunks in $name", ({ source }) => { + withTarball( + ["dist/index.js"], + { "dist/index.js": source }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("dist/index.js imports missing dist/missing.js"); + }, + "2026.4.27", + ); + }); + + it("does not reject import-like text inside packaged template literals", () => { + withTarball( + ["dist/index.js"], + { "dist/index.js": 'const example = `\nimport "./phantom.js"\n`;\n' }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("OpenClaw package tarball integrity passed."); + }, + "2026.4.27", + ); + }); + it("accepts dist files whose relative chunks are present", () => { withTarball( ["dist/cli/run-main.js", "dist/memory-state-current.js"], @@ -393,6 +453,23 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("rejects named imported chunks omitted from the postinstall inventory", () => { + withTarball( + ["dist/index.js"], + { + "dist/index.js": 'import { value } from "./chunk.js";\nexport { value };\n', + "dist/chunk.js": "export const value = 42;\n", + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("inventory omits imported dist file dist/chunk.js"); + }, + "2026.4.27", + ); + }); + it("rejects CommonJS require chunks omitted from the postinstall inventory", () => { withTarball( ["dist/index.cjs"], @@ -477,6 +554,27 @@ describe("check-openclaw-package-tarball", () => { ); }); + it.each([ + "../../openclaw.mjs", + "../../scripts/run-node.mjs", + "../../dist/entry.js", + "../../dist/entry.mjs", + ])("allows import.meta.url JavaScript probes outside packaged dist (%s)", (specifier) => { + withTarball( + ["dist/index.js"], + { + "dist/index.js": `const candidate = new URL(${JSON.stringify(specifier)}, import.meta.url);\n`, + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("OpenClaw package tarball integrity passed."); + }, + "2026.4.27", + ); + }); + it("allows import.meta.url source helper probes", () => { withTarball( ["dist/index.js"], diff --git a/test/scripts/check-package-dist-imports.test.ts b/test/scripts/check-package-dist-imports.test.ts index 479c2aedfd36..4d18ecfe68da 100644 --- a/test/scripts/check-package-dist-imports.test.ts +++ b/test/scripts/check-package-dist-imports.test.ts @@ -47,6 +47,69 @@ describe("check-package-dist-imports", () => { expect(result.stdout).toContain("OpenClaw package dist import closure passed."); }); + it.each([ + { + name: "named imports", + source: 'import { value } from "./missing.js";\n', + }, + { + name: "multiline named imports", + source: 'import {\n value,\n} from "./missing.js";\n', + }, + { + name: "named re-exports", + source: 'export { value } from "./missing.js";\n', + }, + { + name: "multiline named re-exports", + source: 'export {\n value,\n} from "./missing.js";\n', + }, + ])("rejects missing chunks in $name", ({ source }) => { + const root = makeTempDir(tempDirs, "openclaw-package-dist-imports-"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "dist", "index.js"), source, "utf8"); + + const result = spawnSync("node", [CHECK_SCRIPT, root], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("dist/index.js imports missing dist/missing.js"); + }); + + it("ignores import-like text inside multiline template literals", () => { + const root = makeTempDir(tempDirs, "openclaw-package-dist-imports-"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist", "index.js"), + 'const example = `\nimport "./phantom.js"\n`;\n', + "utf8", + ); + + const result = spawnSync("node", [CHECK_SCRIPT, root], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("OpenClaw package dist import closure passed."); + }); + + it.each([ + "../../openclaw.mjs", + "../../scripts/run-node.mjs", + "../../dist/entry.js", + "../../dist/entry.mjs", + ])("ignores import.meta.url probes outside packaged dist (%s)", (specifier) => { + const root = makeTempDir(tempDirs, "openclaw-package-dist-imports-"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist", "index.js"), + `const candidate = new URL(${JSON.stringify(specifier)}, import.meta.url);\n`, + "utf8", + ); + + const result = spawnSync("node", [CHECK_SCRIPT, root], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("OpenClaw package dist import closure passed."); + }); + it("rejects missing CommonJS require chunks", () => { const root = makeTempDir(tempDirs, "openclaw-package-dist-imports-"); mkdirSync(join(root, "dist"), { recursive: true }); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index c4c5dc547e10..658c7dddf1bf 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -5066,10 +5066,13 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(smokeBuildStep.run).toContain("node scripts/build-all.mjs qaRuntime"); expect(smokeBuildStep.run).toContain("pnpm ui:build"); expect(smokeBuildStep.env.OPENCLAW_BUILD_PRIVATE_QA).toBe("1"); - expect(smokeBuildStep.run).toContain("--skip-build"); + expect(smokeBuildStep.run).not.toContain("--skip-build"); expect(smokeBuildStep.run).toContain("--allow-unreleased-changelog"); expect(smokeBuildStep.run).toContain("grep -Fq"); expect(smokeBuildStep.run).toContain('"${package_args[@]}"'); + expect(smokeBuildStep.run.indexOf("node scripts/package-openclaw-for-docker.mjs")).toBeLessThan( + smokeBuildStep.run.indexOf("node scripts/build-all.mjs qaRuntime"), + ); expect(workflow.jobs["qa-smoke-ci-artifacts"]).toBeUndefined(); expect(workflow.jobs["qa-smoke-ci"]).toBeUndefined(); expect(smokeProfileJob.needs).toEqual(["preflight"]); diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 1e78a81d3246..d3881c54fb82 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -114,6 +114,16 @@ describe("bundled plugin postinstall", () => { fileURLToPath(new URL("../../scripts/lib/package-dist-imports.mjs", import.meta.url)), path.join(scriptRoot, "lib", "package-dist-imports.mjs"), ); + await fs.copyFile( + fileURLToPath(new URL("../../scripts/lib/guard-inventory-utils.mjs", import.meta.url)), + path.join(scriptRoot, "lib", "guard-inventory-utils.mjs"), + ); + await fs.mkdir(path.join(packageRoot, "node_modules"), { recursive: true }); + await fs.symlink( + fileURLToPath(new URL("../../node_modules/typescript", import.meta.url)), + path.join(packageRoot, "node_modules", "typescript"), + process.platform === "win32" ? "junction" : "dir", + ); for (const sentinel of sentinels) { await fs.mkdir(path.dirname(sentinel), { recursive: true }); await fs.writeFile(sentinel, "owned by another Node application\n"); @@ -691,6 +701,40 @@ describe("bundled plugin postinstall", () => { await expectPathMissing(staleFile); }); + it("keeps named imported chunks without preserving template-literal pseudoimports", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-install-named-import-"); + const entryFile = path.join(packageRoot, "dist", "cli", "run-main.js"); + const importedChunk = path.join(packageRoot, "dist", "memory-state-current.js"); + const phantomChunk = path.join(packageRoot, "dist", "memory-state-phantom.js"); + await fs.mkdir(path.dirname(entryFile), { recursive: true }); + await fs.writeFile( + entryFile, + [ + "import {", + " value,", + '} from "../memory-state-current.js";', + "const example = `", + 'import "../memory-state-phantom.js"', + "`;", + "export { value, example };", + "", + ].join("\n"), + ); + await writePackageDistInventory(packageRoot); + await fs.writeFile(importedChunk, "export const value = 42;\n"); + await fs.writeFile(phantomChunk, "export const stale = true;\n"); + + expect( + pruneInstalledPackageDist({ + packageRoot, + log: { log: vi.fn(), warn: vi.fn() }, + }), + ).toEqual(["dist/memory-state-phantom.js"]); + + await expectPathExists(importedChunk); + await expectPathMissing(phantomChunk); + }); + it("does not abort dist pruning when a listed chunk disappears before import expansion", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-missing-chunk-"); const entryFile = path.join(packageRoot, "dist", "control-ui", "assets", "instances.js");