diff --git a/CHANGELOG.md b/CHANGELOG.md index 725747ca31fc..8de3b741f134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai - Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens. - CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo. - Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23. +- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng. ## 2026.5.17 diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index f9fcd369c9f6..1c098e281e82 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -13,7 +13,7 @@ import { import { builtinModules } from "node:module"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { isAbsolute, join, relative } from "node:path"; +import { dirname, 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"; @@ -129,6 +129,7 @@ export function collectInstalledPackageErrors(params: { } errors.push(...collectInstalledContextEngineRuntimeErrors(params.packageRoot)); + errors.push(...collectInstalledPluginSdkZodArtifactErrors(params.packageRoot)); errors.push(...collectInstalledRootDependencyManifestErrors(params.packageRoot)); return errors; @@ -214,6 +215,97 @@ export function collectInstalledContextEngineRuntimeErrors(packageRoot: string): return errors; } +function resolveInstalledDistRelativeImport(params: { + distRoot: string; + importerPath: string; + specifier: string; +}): string | null { + if (!params.specifier.startsWith(".")) { + return null; + } + + const candidatePath = join(dirname(params.importerPath), params.specifier); + const candidatePaths = [ + candidatePath, + `${candidatePath}.js`, + `${candidatePath}.mjs`, + `${candidatePath}.cjs`, + join(candidatePath, "index.js"), + join(candidatePath, "index.mjs"), + join(candidatePath, "index.cjs"), + ]; + + for (const resolvedPath of candidatePaths) { + const relativePath = relative(params.distRoot, resolvedPath); + if ( + relativePath.length === 0 || + relativePath.startsWith("..") || + isAbsolute(relativePath) || + !existsSync(resolvedPath) + ) { + continue; + } + return resolvedPath; + } + + return null; +} + +export function collectInstalledPluginSdkZodArtifactErrors(packageRoot: string): string[] { + const distRoot = join(packageRoot, "dist"); + const entryRelativePath = "dist/plugin-sdk/zod.js"; + const entryPath = join(packageRoot, entryRelativePath); + const pending = [entryPath]; + const visited = new Set(); + + while (pending.length > 0) { + const filePath = pending.pop(); + if (!filePath || visited.has(filePath)) { + continue; + } + visited.add(filePath); + + if (!existsSync(filePath)) { + return [`installed package is missing required plugin SDK artifact: ${entryRelativePath}`]; + } + + const relativePath = relative(packageRoot, filePath).replaceAll("\\", "/"); + const fileStat = lstatSync(filePath); + if (!fileStat.isFile() || fileStat.size > MAX_INSTALLED_ROOT_DIST_JS_BYTES) { + return [ + `installed package plugin SDK artifact '${relativePath}' is invalid or exceeds ${MAX_INSTALLED_ROOT_DIST_JS_BYTES} bytes.`, + ]; + } + + const source = readFileSync(filePath, "utf8"); + const parsedSpecifiers = extractJavaScriptImportSpecifiers(source); + if (!parsedSpecifiers.ok) { + return [ + `installed package plugin SDK artifact '${relativePath}' could not be parsed for runtime dependency verification: ${parsedSpecifiers.error}.`, + ]; + } + + for (const specifier of parsedSpecifiers.specifiers) { + if (specifier === "zod" || specifier.startsWith("zod/")) { + return [ + `installed package plugin SDK zod artifact must be self-contained but ${relativePath} imports ${specifier}.`, + ]; + } + + const resolvedPath = resolveInstalledDistRelativeImport({ + distRoot, + importerPath: filePath, + specifier, + }); + if (resolvedPath) { + pending.push(resolvedPath); + } + } + } + + return []; +} + function listInstalledRootDistJavaScriptFiles(packageRoot: string): string[] { return listDistJavaScriptFiles(packageRoot, { skipRelativePath: (relativePath) => relativePath.startsWith("extensions/"), diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 33a09834907e..d5aa1e4fcb65 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -5,6 +5,7 @@ import tsdownConfig from "../../tsdown.config.ts"; type TsdownConfigEntry = { deps?: { + alwaysBundle?: string[] | ((id: string) => boolean); neverBundle?: string[] | ((id: string) => boolean); }; entry?: Record | string[]; @@ -226,6 +227,21 @@ describe("tsdown config", () => { expect(externalize("qrcode-terminal/lib/main.js", undefined, false)).toBe(true); }); + it("always bundles plugin SDK package-local runtime dependencies", () => { + const unifiedGraph = requireUnifiedDistGraph(); + const alwaysBundle = unifiedGraph.deps?.alwaysBundle; + + if (typeof alwaysBundle !== "function") { + throw new Error("expected unified graph alwaysBundle predicate"); + } + + expect(alwaysBundle("@openclaw/fs-safe")).toBe(true); + expect(alwaysBundle("@openclaw/fs-safe/path")).toBe(true); + expect(alwaysBundle("zod")).toBe(true); + expect(alwaysBundle("zod/v4/core")).toBe(true); + expect(alwaysBundle("not-a-runtime-dependency")).toBe(false); + }); + it("suppresses unresolved imports from extension source", () => { const configured = unifiedDistGraph()?.inputOptions?.({})?.onLog; const handled: TsdownLog[] = []; diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 0521fc99cbad..b9d339328e8b 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -7,6 +7,7 @@ import { buildPublishedInstallScenarios, collectInstalledBundledRuntimeSidecarPaths, collectInstalledContextEngineRuntimeErrors, + collectInstalledPluginSdkZodArtifactErrors, collectInstalledRootDependencyManifestErrors, collectInstalledPackageErrors, normalizeInstalledBinaryVersion, @@ -146,6 +147,77 @@ describe("collectInstalledContextEngineRuntimeErrors", () => { }); }); +describe("collectInstalledPluginSdkZodArtifactErrors", () => { + function withInstalledPackageRoot(run: (packageRoot: string) => void): void { + const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-postpublish-zod-sdk-")); + try { + run(packageRoot); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + } + + function writeInstalledFile(packageRoot: string, relativePath: string, contents: string): void { + const filePath = join(packageRoot, ...relativePath.split("/")); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents, "utf8"); + } + + it("requires the plugin-sdk zod artifact", () => { + withInstalledPackageRoot((packageRoot) => { + expect(collectInstalledPluginSdkZodArtifactErrors(packageRoot)).toEqual([ + "installed package is missing required plugin SDK artifact: dist/plugin-sdk/zod.js", + ]); + }); + }); + + it("rejects plugin-sdk zod artifacts with a bare zod export", () => { + withInstalledPackageRoot((packageRoot) => { + writeInstalledFile( + packageRoot, + "dist/plugin-sdk/zod.js", + 'import "../zod-D2c0iocA.js";\nexport * from "zod";\n', + ); + + expect(collectInstalledPluginSdkZodArtifactErrors(packageRoot)).toEqual([ + "installed package plugin SDK zod artifact must be self-contained but dist/plugin-sdk/zod.js imports zod.", + ]); + }); + }); + + it("rejects plugin-sdk zod artifacts when a reachable local chunk imports zod", () => { + withInstalledPackageRoot((packageRoot) => { + writeInstalledFile( + packageRoot, + "dist/plugin-sdk/zod.js", + 'export { z } from "../zod-D2c0iocA.js";\n', + ); + writeInstalledFile( + packageRoot, + "dist/zod-D2c0iocA.js", + 'import * as zodCore from "zod/v4/core";\nexport const z = zodCore;\n', + ); + + expect(collectInstalledPluginSdkZodArtifactErrors(packageRoot)).toEqual([ + "installed package plugin SDK zod artifact must be self-contained but dist/zod-D2c0iocA.js imports zod/v4/core.", + ]); + }); + }); + + it("accepts plugin-sdk zod artifacts that only import package-local chunks", () => { + withInstalledPackageRoot((packageRoot) => { + writeInstalledFile( + packageRoot, + "dist/plugin-sdk/zod.js", + 'export { z } from "../zod-D2c0iocA.js";\n', + ); + writeInstalledFile(packageRoot, "dist/zod-D2c0iocA.js", "export const z = {};\n"); + + expect(collectInstalledPluginSdkZodArtifactErrors(packageRoot)).toEqual([]); + }); + }); +}); + describe("normalizeInstalledBinaryVersion", () => { it("accepts decorated CLI version output", () => { expect(normalizeInstalledBinaryVersion("OpenClaw 2026.4.8 (9ece252)")).toBe("2026.4.8"); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 25888d883918..e2f2e99bec45 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -553,6 +553,7 @@ describe("collectMissingPackPaths", () => { packageRoot, }), ).toEqual([ + "installed package is missing required plugin SDK artifact: dist/plugin-sdk/zod.js", "installed package root dist file 'typescript-compiler.js' is invalid or exceeds 6291456 bytes.", ]); } finally { diff --git a/tsdown.config.ts b/tsdown.config.ts index d914a37014ae..ed45819f3f27 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -183,7 +183,12 @@ function shouldNeverBundleDependency(id: string): boolean { } function shouldAlwaysBundleDependency(id: string): boolean { - return id === "@openclaw/fs-safe" || id.startsWith("@openclaw/fs-safe/"); + return ( + id === "@openclaw/fs-safe" || + id.startsWith("@openclaw/fs-safe/") || + id === "zod" || + id.startsWith("zod/") + ); } function listBundledPluginEntrySources(