From 645294510c5e179cde50a104b58694a261ced629 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 16:09:41 +0100 Subject: [PATCH] fix: restore bundled plugin SDK alias --- CHANGELOG.md | 1 + .../loader-sdk-import-guardrails.test.ts | 2 + src/plugins/loader.test.ts | 175 ++++++++++++++++++ src/plugins/loader.ts | 72 +++++++ 4 files changed, 250 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9892a1bdcdf..c87bdb60a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/startup: restore bundled plugin `openclaw/plugin-sdk/*` resolution from packaged installs and external runtime-deps stage roots, so Telegram/Discord no longer crash-loop with `Cannot find package 'openclaw'` after missing dependency repair. - CLI/Claude: run the same prompt-build hooks and trigger/channel context on `claude-cli` turns as on direct embedded runs, keeping Claude Code sessions aligned with OpenClaw workspace identity, routing, and hook-driven prompt mutations. (#70625) Thanks @mbelinky. ## 2026.4.22 diff --git a/src/plugins/loader-sdk-import-guardrails.test.ts b/src/plugins/loader-sdk-import-guardrails.test.ts index a76634321c4..6cf8018e728 100644 --- a/src/plugins/loader-sdk-import-guardrails.test.ts +++ b/src/plugins/loader-sdk-import-guardrails.test.ts @@ -9,6 +9,8 @@ const ALLOWED_PLUGIN_SDK_FIXTURE_IMPORTS = new Set([ // Intentional jiti alias regression test. 'src/plugins/loader.git-path-regression.test.ts:`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";', 'src/plugins/loader.git-path-regression.test.ts: "openclaw/plugin-sdk/infra-runtime": ${JSON.stringify(copiedChannelRuntimeShim)},', + // Intentional packaged bundled-plugin SDK alias regression tests. + 'src/plugins/loader.test.ts:`import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";`,', ]); const LOADER_FIXTURE_TEST_FILES = [ diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 55a0aa330ce..5a8291f3974 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1454,6 +1454,181 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); }); + it("loads bundled plugins with plugin-sdk imports from an external stage dir", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + const bundledDir = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledDir, "telegram"); + fs.mkdirSync(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.22", type: "module" }), + "utf-8", + ); + fs.writeFileSync( + path.join(packageRoot, "dist", "plugin-sdk", "text-runtime.js"), + [ + `export function normalizeLowercaseStringOrEmpty(value) {`, + ` return typeof value === "string" ? value.toLowerCase() : "";`, + `}`, + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "index.js"), + [ + `import runtimeDep from "external-runtime";`, + `import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";`, + `export default {`, + ` id: "telegram",`, + ` register(api) {`, + ` api.registerCommand({`, + ` name: "external-runtime",`, + ` handler: () => normalizeLowercaseStringOrEmpty(runtimeDep.marker),`, + ` });`, + ` },`, + `};`, + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/telegram", + version: "1.0.0", + type: "module", + dependencies: { + "external-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "telegram", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: ({ installRoot }) => { + const depRoot = path.join(installRoot, "node_modules", "external-runtime"); + fs.mkdirSync(depRoot, { recursive: true }); + fs.writeFileSync( + path.join(depRoot, "package.json"), + JSON.stringify({ + name: "external-runtime", + version: "1.0.0", + type: "module", + exports: "./index.js", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(depRoot, "index.js"), + "export default { marker: 'SDK-OK' };\n", + "utf-8", + ); + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "telegram")?.status).toBe("loaded"); + }); + + it("loads bundled plugins with plugin-sdk imports from a package dist root", () => { + const packageRoot = makeTempDir(); + const bundledDir = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledDir, "discord"); + fs.mkdirSync(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.22", type: "module" }), + "utf-8", + ); + fs.writeFileSync( + path.join(packageRoot, "dist", "plugin-sdk", "text-runtime.js"), + "export const normalizeLowercaseStringOrEmpty = (value) => String(value).toLowerCase();\n", + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "index.js"), + [ + `import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";`, + `export default {`, + ` id: "discord",`, + ` register(api) {`, + ` api.registerCommand({ name: normalizeLowercaseStringOrEmpty("DISCORD"), handler: () => "ok" });`, + ` },`, + `};`, + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/discord", + version: "1.0.0", + type: "module", + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "discord", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded"); + }); + it("loads dist-runtime wrappers from an external stage dir", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index bab73e4e33a..4011cfe7477 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -599,6 +599,7 @@ function prepareBundledPluginRuntimeDistMirror(params: { } } } + ensureOpenClawPluginSdkAlias(mirrorDistRoot); return mirrorExtensionsRoot; } @@ -631,6 +632,76 @@ function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): v } } +function writeRuntimeJsonFile(targetPath: string, value: unknown): void { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function hasRuntimeDefaultExport(sourcePath: string): boolean { + const text = fs.readFileSync(sourcePath, "utf8"); + return /\bexport\s+default\b/u.test(text) || /\bas\s+default\b/u.test(text); +} + +function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void { + const specifier = path.relative(path.dirname(targetPath), sourcePath).replaceAll(path.sep, "/"); + const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`; + const defaultForwarder = hasRuntimeDefaultExport(sourcePath) + ? [ + `import defaultModule from ${JSON.stringify(normalizedSpecifier)};`, + `let defaultExport = defaultModule;`, + `for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`, + ` defaultExport = defaultExport.default;`, + `}`, + ] + : [ + `import * as module from ${JSON.stringify(normalizedSpecifier)};`, + `let defaultExport = "default" in module ? module.default : module;`, + `for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`, + ` defaultExport = defaultExport.default;`, + `}`, + ]; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync( + targetPath, + [ + `export * from ${JSON.stringify(normalizedSpecifier)};`, + ...defaultForwarder, + "export { defaultExport as default };", + "", + ].join("\n"), + "utf8", + ); +} + +function ensureOpenClawPluginSdkAlias(distRoot: string): void { + const pluginSdkDir = path.join(distRoot, "plugin-sdk"); + if (!fs.existsSync(pluginSdkDir)) { + return; + } + + const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw"); + const pluginSdkAliasDir = path.join(aliasDir, "plugin-sdk"); + writeRuntimeJsonFile(path.join(aliasDir, "package.json"), { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/*": "./plugin-sdk/*.js", + }, + }); + fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true }); + fs.mkdirSync(pluginSdkAliasDir, { recursive: true }); + for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) { + if (!entry.isFile() || path.extname(entry.name) !== ".js") { + continue; + } + writeRuntimeModuleWrapper( + path.join(pluginSdkDir, entry.name), + path.join(pluginSdkAliasDir, entry.name), + ); + } +} + function remapBundledPluginRuntimePath(params: { source: string | undefined; pluginRoot: string; @@ -2137,6 +2208,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi ); } } + ensureOpenClawPluginSdkAlias(path.dirname(path.dirname(pluginRoot))); if (path.resolve(installRoot) !== path.resolve(pluginRoot)) { registerBundledRuntimeDependencyNodePath(installRoot); runtimePluginRoot = mirrorBundledPluginRuntimeRoot({