diff --git a/CHANGELOG.md b/CHANGELOG.md index fb99dc42dea..26a3cda66bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai - OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00. - Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00. - Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus. +- Build/npm publish: fail postpublish verification when root `dist/*` files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns. ## 2026.4.21 diff --git a/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs b/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs index 7ae46cbce66..907098609a4 100644 --- a/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs +++ b/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs @@ -151,6 +151,10 @@ function extractModuleSpecifiers(source) { return specifiers; } +function isPluginOwnedDistImporter(relativePath, pluginIds) { + return pluginIds.some((pluginId) => relativePath.startsWith(`extensions/${pluginId}/`)); +} + export function collectRootDistBundledRuntimeMirrors(params) { const distDir = params.distDir; const bundledSpecs = params.bundledRuntimeDependencySpecs; @@ -177,6 +181,9 @@ export function collectRootDistBundledRuntimeMirrors(params) { continue; } const bundledSpec = bundledSpecs.get(dependencyName); + if (isPluginOwnedDistImporter(relativePath, bundledSpec.pluginIds)) { + continue; + } const existing = mirrors.get(dependencyName); if (existing) { existing.importers.add(relativePath); @@ -195,6 +202,7 @@ export function collectRootDistBundledRuntimeMirrors(params) { export function collectBundledPluginRootRuntimeMirrorErrors(params) { const errors = []; + const declaredRootRuntimeDeps = collectRuntimeDependencySpecs(params.rootPackageJson); for (const [dependencyName, record] of params.bundledRuntimeDependencySpecs) { for (const conflict of record.conflicts) { @@ -204,5 +212,17 @@ export function collectBundledPluginRootRuntimeMirrorErrors(params) { } } - return errors; + for (const [dependencyName, record] of params.requiredRootMirrors) { + if (declaredRootRuntimeDeps.has(dependencyName)) { + continue; + } + const importerList = Array.from(record.importers) + .toSorted((left, right) => left.localeCompare(right)) + .join(", "); + errors.push( + `installed package root is missing mirrored bundled runtime dependency '${dependencyName}' for dist importers: ${importerList}. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/${record.pluginIds[0]}/.`, + ); + } + + return errors.toSorted((left, right) => left.localeCompare(right)); } diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 2d8be8485be..c955400bbf8 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -9,9 +9,6 @@ type TsdownConfigEntry = { entry?: Record | string[]; inputOptions?: TsdownInputOptions; outDir?: string; - outputOptions?: (options: unknown) => { - chunkFileNames?: (chunkInfo: { name: string; moduleIds: string[] }) => string; - }; }; type TsdownLog = { @@ -148,117 +145,4 @@ describe("tsdown config", () => { expect(handled).toEqual([log]); }); - - it("routes bundled plugin shared chunks to their own directory", () => { - const configs = asConfigArray(tsdownConfig); - const unifiedGraph = configs.find((config) => entryKeys(config).includes("index")); - expect(unifiedGraph).toBeDefined(); - - // Extract the chunkFileNames function from outputOptions - const outputOptionsFn = unifiedGraph!.outputOptions; - expect(typeof outputOptionsFn).toBe("function"); - - const outputOptions = outputOptionsFn!({}); - const chunkFileNames = outputOptions.chunkFileNames!; - expect(typeof chunkFileNames).toBe("function"); - - // Scenario 1: A chunk containing only slack files - expect( - chunkFileNames({ - name: "shared-slack-api", - moduleIds: [ - "extensions/slack/src/api.ts", - "extensions/slack/src/token.ts", - ], - }), - ).toBe("extensions/slack/[name]-[hash].js"); - - // Scenario 2: A chunk containing only telegram files - expect( - chunkFileNames({ - name: "shared-telegram-api", - moduleIds: [ - "extensions/telegram/src/api.ts", - "extensions/telegram/src/config.ts", - ], - }), - ).toBe("extensions/telegram/[name]-[hash].js"); - - // Scenario 3: A chunk containing mixed files (architectural violation) - expect( - chunkFileNames({ - name: "shared-mixed", - moduleIds: [ - "extensions/slack/src/api.ts", - "extensions/telegram/src/api.ts", - ], - }), - ).toBe("[name]-[hash].js"); - - // Scenario 4: A chunk containing only core files - expect( - chunkFileNames({ - name: "shared-core", - moduleIds: [ - "src/gateway/server-http.ts", - "src/gateway/client.ts", - ], - }), - ).toBe("[name]-[hash].js"); - - // Scenario 5: A chunk containing plugin and core files - expect( - chunkFileNames({ - name: "shared-plugin-and-core", - moduleIds: [ - "extensions/slack/src/api.ts", - "src/gateway/server-http.ts", - ], - }), - ).toBe("[name]-[hash].js"); - - // Scenario 5b: A chunk containing plugin files and virtual modules - expect( - chunkFileNames({ - name: "shared-plugin-with-virtual", - moduleIds: [ - "extensions/slack/src/api.ts", - "\0commonjsHelpers.js", - ], - }), - ).toBe("extensions/slack/[name]-[hash].js"); - - // Scenario 5c: A chunk containing plugin files and node_modules dependencies - expect( - chunkFileNames({ - name: "shared-plugin-with-deps", - moduleIds: [ - "extensions/slack/src/api.ts", - "node_modules/@slack/web-api/index.js", - ], - }), - ).toBe("extensions/slack/[name]-[hash].js"); - - // Scenario 6: Fallback to previous function - const outputOptionsWithFn = outputOptionsFn!({ - chunkFileNames: () => "custom-fn-[hash].js", - }); - expect( - outputOptionsWithFn.chunkFileNames!({ - name: "shared-core", - moduleIds: ["src/gateway/server-http.ts"], - }), - ).toBe("custom-fn-[hash].js"); - - // Scenario 7: Fallback to previous string - const outputOptionsWithStr = outputOptionsFn!({ - chunkFileNames: "custom-str-[hash].js", - }); - expect( - outputOptionsWithStr.chunkFileNames!({ - name: "shared-core", - moduleIds: ["src/gateway/server-http.ts"], - }), - ).toBe("custom-str-[hash].js"); - }); }); diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 0f6ae2183e8..75444904187 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -166,28 +166,49 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } - function writeSlackWebApiProbePackage( - root: string, - dependencies: Record = {}, - ): void { - writePackageFile(root, "package.json", { + function writeSlackWebApiProbePackage(params: { + root: string; + importerPath?: string; + rootDependencies?: Record; + rootOptionalDependencies?: Record; + }): void { + writePackageFile(params.root, "package.json", { version: "2026.4.10", - dependencies, + dependencies: params.rootDependencies, + optionalDependencies: params.rootOptionalDependencies, }); - writePackageFile(root, "dist/extensions/slack/package.json", { + writePackageFile(params.root, "dist/extensions/slack/package.json", { dependencies: { "@slack/web-api": "^7.15.0", }, }); - mkdirSync(join(root, "dist"), { recursive: true }); - writeFileSync(join(root, "dist", "probe-Cz2PiFtC.js"), 'import("@slack/web-api");\n', "utf8"); + const importerPath = params.importerPath ?? "dist/probe-Cz2PiFtC.js"; + mkdirSync(join(params.root, "dist"), { recursive: true }); + writeFileSync(join(params.root, importerPath), 'import("@slack/web-api");\n', "utf8"); } - it("does not require root mirrors for bundled plugin deps imported by root dist", () => { + it("flags bundled plugin deps imported by root dist when root mirrors are missing", () => { const packageRoot = makeInstalledPackageRoot(); try { - writeSlackWebApiProbePackage(packageRoot); + writeSlackWebApiProbePackage({ root: packageRoot }); + + expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ + "installed package root is missing mirrored bundled runtime dependency '@slack/web-api' for dist importers: probe-Cz2PiFtC.js. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/slack/.", + ]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("allows bundled plugin deps imported from their own extension dist without root mirrors", () => { + const packageRoot = makeInstalledPackageRoot(); + + try { + writeSlackWebApiProbePackage({ + root: packageRoot, + importerPath: "dist/extensions/slack/client-Cz2PiFtC.js", + }); expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); } finally { @@ -227,8 +248,11 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { const packageRoot = makeInstalledPackageRoot(); try { - writeSlackWebApiProbePackage(packageRoot, { - "@slack/web-api": "^7.16.0", + writeSlackWebApiProbePackage({ + root: packageRoot, + rootDependencies: { + "@slack/web-api": "^7.16.0", + }, }); expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); diff --git a/tsdown.config.ts b/tsdown.config.ts index 70842b681fe..0d7973cca81 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -88,44 +88,6 @@ function nodeBuildConfig(config: UserConfig): UserConfig { fixedExtension: false, platform: "node", inputOptions: buildInputOptions, - outputOptions(options) { - const previousChunkFileNames = options.chunkFileNames; - return { - ...options, - chunkFileNames(chunkInfo) { - const moduleIds = chunkInfo.moduleIds || []; - const extensionIds = new Set(); - let hasNonPluginModules = false; - for (const id of moduleIds) { - if (id.startsWith("\0")) { - continue; - } - const absoluteId = path.resolve(process.cwd(), id); - const relativeToRoot = path.relative(process.cwd(), absoluteId); - const parts = relativeToRoot.split(path.sep); - - if (parts[0] === "extensions" && parts.length > 2) { - extensionIds.add(parts[1]); - } else if (parts.includes("node_modules")) { - continue; - } else { - hasNonPluginModules = true; - } - } - if (extensionIds.size === 1 && !hasNonPluginModules) { - const extId = Array.from(extensionIds)[0]; - return `extensions/${extId}/[name]-[hash].js`; - } - if (typeof previousChunkFileNames === "function") { - return previousChunkFileNames(chunkInfo); - } - if (typeof previousChunkFileNames === "string") { - return previousChunkFileNames; - } - return `[name]-[hash].js`; - }, - }; - }, }; }