From 175473c927a80b6a664c6d6a829c0b0e6e948dd1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 15 Apr 2026 09:26:25 -0400 Subject: [PATCH] fix(plugins): stabilize bundled setup runtimes --- docs/plugins/sdk-channel-plugins.md | 5 + docs/plugins/sdk-entrypoints.md | 25 +++ docs/plugins/sdk-runtime.md | 9 +- docs/plugins/sdk-setup.md | 6 + docs/plugins/sdk-testing.md | 5 +- extensions/bluebubbles/src/runtime.ts | 5 +- extensions/discord/src/runtime.ts | 5 +- extensions/feishu/src/runtime.ts | 5 +- extensions/googlechat/src/runtime.ts | 5 +- extensions/imessage/src/runtime.ts | 5 +- extensions/irc/src/runtime.ts | 12 +- extensions/line/src/runtime.ts | 5 +- extensions/matrix/setup-entry.ts | 4 + extensions/matrix/src/runtime.ts | 5 +- extensions/mattermost/src/runtime.ts | 5 +- extensions/msteams/src/runtime.ts | 5 +- extensions/nextcloud-talk/src/runtime.ts | 5 +- extensions/nostr/src/runtime.ts | 5 +- extensions/qqbot/src/runtime.ts | 5 +- extensions/signal/src/runtime.ts | 5 +- extensions/slack/src/runtime.ts | 5 +- extensions/synology-chat/src/runtime.ts | 7 +- extensions/telegram/src/runtime.ts | 5 +- extensions/tlon/src/runtime.ts | 5 +- extensions/twitch/src/runtime.ts | 5 +- extensions/whatsapp/src/runtime.ts | 5 +- extensions/zalo/src/runtime.ts | 5 +- extensions/zalouser/src/runtime.ts | 5 +- .../plugins/bundled.shape-guard.test.ts | 88 +++++++++ src/channels/plugins/bundled.ts | 48 +++-- src/plugin-sdk/channel-entry-contract.ts | 16 ++ src/plugin-sdk/runtime-store.test.ts | 96 ++++++++++ src/plugin-sdk/runtime-store.ts | 72 ++++++- src/plugins/loader.test.ts | 115 +++++++++++- src/plugins/loader.ts | 177 +++++++++++++++++- 35 files changed, 727 insertions(+), 58 deletions(-) create mode 100644 src/plugin-sdk/runtime-store.test.ts diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 5e51b8b6e4a..803f38f177a 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -493,6 +493,11 @@ should use `resolveInboundMentionDecision({ facts, policy })`. or unconfigured. It avoids pulling in heavy runtime code during setup flows. See [Setup and Config](/plugins/sdk-setup#setup-entry) for details. + Bundled workspace channels that split setup-safe exports into sidecar + modules can use `defineBundledChannelSetupEntry(...)` from + `openclaw/plugin-sdk/channel-entry-contract` when they also need an + explicit setup-time runtime setter. + diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 79a6e441f5d..3cd11df080c 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -145,6 +145,31 @@ families: Keep heavy SDKs, CLI registration, and long-lived runtime services in the full entry. +Bundled workspace channels that split setup and runtime surfaces can use +`defineBundledChannelSetupEntry(...)` from +`openclaw/plugin-sdk/channel-entry-contract` instead. That contract lets the +setup entry keep setup-safe plugin/secrets exports while still exposing a +runtime setter: + +```typescript +import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; + +export default defineBundledChannelSetupEntry({ + importMetaUrl: import.meta.url, + plugin: { + specifier: "./channel-plugin-api.js", + exportName: "myChannelPlugin", + }, + runtime: { + specifier: "./runtime-api.js", + exportName: "setMyChannelRuntime", + }, +}); +``` + +Use that bundled contract only when setup flows truly need a lightweight runtime +setter before the full channel entry loads. + ## Registration mode `api.registrationMode` tells your plugin how it was loaded: diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 94105634ad8..7d3faf9bb28 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -385,7 +385,10 @@ the `register` callback: import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; -const store = createPluginRuntimeStore("my-plugin runtime not initialized"); +const store = createPluginRuntimeStore({ + pluginId: "my-plugin", + errorMessage: "my-plugin runtime not initialized", +}); // In your entry point export default defineChannelPluginEntry({ @@ -406,6 +409,10 @@ export function tryGetRuntime() { } ``` +Prefer `pluginId` for the runtime-store identity. The lower-level `key` form is +for uncommon cases where one plugin intentionally needs more than one runtime +slot. + ## Other top-level `api` fields Beyond `api.runtime`, the API object also provides: diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 33cfbf6f5b9..81ba55de397 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -279,6 +279,12 @@ export default defineSetupPluginEntry(myChannelPlugin); This avoids loading heavy runtime code (crypto libraries, CLI registrations, background services) during setup flows. +Bundled workspace channels that keep setup-safe exports in sidecar modules can +use `defineBundledChannelSetupEntry(...)` from +`openclaw/plugin-sdk/channel-entry-contract` instead of +`defineSetupPluginEntry(...)`. That bundled contract also supports an optional +`runtime` export so setup-time runtime wiring can stay lightweight and explicit. + **When OpenClaw uses `setupEntry` instead of the full entry:** - The channel is disabled but needs setup/onboarding surfaces diff --git a/docs/plugins/sdk-testing.md b/docs/plugins/sdk-testing.md index 82ddec9d410..dbb1dbdfe9f 100644 --- a/docs/plugins/sdk-testing.md +++ b/docs/plugins/sdk-testing.md @@ -155,7 +155,10 @@ For code that uses `createPluginRuntimeStore`, mock the runtime in tests: import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; -const store = createPluginRuntimeStore("test runtime not set"); +const store = createPluginRuntimeStore({ + pluginId: "test-plugin", + errorMessage: "test runtime not set", +}); // In test setup const mockRuntime = { diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 2ac1c68ad91..88eb3038b5a 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,7 +1,10 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "./runtime-api.js"; -const runtimeStore = createPluginRuntimeStore("BlueBubbles runtime not initialized"); +const runtimeStore = createPluginRuntimeStore({ + pluginId: "bluebubbles", + errorMessage: "BlueBubbles runtime not initialized", +}); type LegacyRuntimeLogShape = { log?: (message: string) => void }; export const setBlueBubblesRuntime = runtimeStore.setRuntime; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 0aa5f660eea..cf703c0edf1 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -16,5 +16,8 @@ const { setRuntime: setDiscordRuntime, tryGetRuntime: getOptionalDiscordRuntime, getRuntime: getDiscordRuntime, -} = createPluginRuntimeStore("Discord runtime not initialized"); +} = createPluginRuntimeStore({ + pluginId: "discord", + errorMessage: "Discord runtime not initialized", +}); export { getDiscordRuntime, getOptionalDiscordRuntime, setDiscordRuntime }; diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index 6abebf398b3..01b0dbb80bc 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } = - createPluginRuntimeStore("Feishu runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "feishu", + errorMessage: "Feishu runtime not initialized", + }); export { getFeishuRuntime, setFeishuRuntime }; diff --git a/extensions/googlechat/src/runtime.ts b/extensions/googlechat/src/runtime.ts index 5409e33ec59..16c61010869 100644 --- a/extensions/googlechat/src/runtime.ts +++ b/extensions/googlechat/src/runtime.ts @@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } = - createPluginRuntimeStore("Google Chat runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "googlechat", + errorMessage: "Google Chat runtime not initialized", + }); export { getGoogleChatRuntime, setGoogleChatRuntime }; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index a7ed927b9ab..f455b33ad4a 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = - createPluginRuntimeStore("iMessage runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "imessage", + errorMessage: "iMessage runtime not initialized", + }); export { getIMessageRuntime, setIMessageRuntime }; diff --git a/extensions/irc/src/runtime.ts b/extensions/irc/src/runtime.ts index a1748dad4d0..87ea65365c3 100644 --- a/extensions/irc/src/runtime.ts +++ b/extensions/irc/src/runtime.ts @@ -1,9 +1,15 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "./runtime-api.js"; -const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } = - createPluginRuntimeStore("IRC runtime not initialized"); +const { + setRuntime: setIrcRuntime, + clearRuntime: clearStoredIrcRuntime, + getRuntime: getIrcRuntime, +} = createPluginRuntimeStore({ + pluginId: "irc", + errorMessage: "IRC runtime not initialized", +}); export { getIrcRuntime, setIrcRuntime }; export function clearIrcRuntime() { - setIrcRuntime(undefined as unknown as PluginRuntime); + clearStoredIrcRuntime(); } diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 62137bb2d39..3c165ebd201 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -25,5 +25,8 @@ const { setRuntime: setLineRuntime, clearRuntime: clearLineRuntime, getRuntime: getLineRuntime, -} = createPluginRuntimeStore("LINE runtime not initialized - plugin not registered"); +} = createPluginRuntimeStore({ + pluginId: "line", + errorMessage: "LINE runtime not initialized - plugin not registered", +}); export { clearLineRuntime, getLineRuntime, setLineRuntime }; diff --git a/extensions/matrix/setup-entry.ts b/extensions/matrix/setup-entry.ts index 3cd065e0856..e148cc933f8 100644 --- a/extensions/matrix/setup-entry.ts +++ b/extensions/matrix/setup-entry.ts @@ -10,4 +10,8 @@ export default defineBundledChannelSetupEntry({ specifier: "./secret-contract-api.js", exportName: "channelSecrets", }, + runtime: { + specifier: "./runtime-api.js", + exportName: "setMatrixRuntime", + }, }); diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index fc20d8bba8a..5f830d03619 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -2,6 +2,9 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "./runtime-api.js"; const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = - createPluginRuntimeStore("Matrix runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "matrix", + errorMessage: "Matrix runtime not initialized", + }); export { getMatrixRuntime, setMatrixRuntime }; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index 5f9c84e1cb2..8a58131be40 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = - createPluginRuntimeStore("Mattermost runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "mattermost", + errorMessage: "Mattermost runtime not initialized", + }); export { getMattermostRuntime, setMattermostRuntime }; diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts index 13effc94345..3184069fcd0 100644 --- a/extensions/msteams/src/runtime.ts +++ b/extensions/msteams/src/runtime.ts @@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } = - createPluginRuntimeStore("MSTeams runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "msteams", + errorMessage: "MSTeams runtime not initialized", + }); export { getMSTeamsRuntime, setMSTeamsRuntime }; diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index 0258ad4e7c1..35eb1cb8f63 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = - createPluginRuntimeStore("Nextcloud Talk runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "nextcloud-talk", + errorMessage: "Nextcloud Talk runtime not initialized", + }); export { getNextcloudTalkRuntime, setNextcloudTalkRuntime }; diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index 52f9e189492..6866046d229 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = - createPluginRuntimeStore("Nostr runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "nostr", + errorMessage: "Nostr runtime not initialized", + }); export { getNostrRuntime, setNostrRuntime }; diff --git a/extensions/qqbot/src/runtime.ts b/extensions/qqbot/src/runtime.ts index 9852a8ffc40..4d2db1314be 100644 --- a/extensions/qqbot/src/runtime.ts +++ b/extensions/qqbot/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setQQBotRuntime, getRuntime: getQQBotRuntime } = - createPluginRuntimeStore("QQBot runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "qqbot", + errorMessage: "QQBot runtime not initialized", + }); export { getQQBotRuntime, setQQBotRuntime }; diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 6d05d001def..b0aed99e28b 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -5,5 +5,8 @@ const { setRuntime: setSignalRuntime, clearRuntime: clearSignalRuntime, getRuntime: getSignalRuntime, -} = createPluginRuntimeStore("Signal runtime not initialized"); +} = createPluginRuntimeStore({ + pluginId: "signal", + errorMessage: "Signal runtime not initialized", +}); export { clearSignalRuntime, getSignalRuntime, setSignalRuntime }; diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 1c668df7484..3c0c82fd27d 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -16,5 +16,8 @@ const { clearRuntime: clearSlackRuntime, tryGetRuntime: getOptionalSlackRuntime, getRuntime: getSlackRuntime, -} = createPluginRuntimeStore("Slack runtime not initialized"); +} = createPluginRuntimeStore({ + pluginId: "slack", + errorMessage: "Slack runtime not initialized", +}); export { clearSlackRuntime, getOptionalSlackRuntime, getSlackRuntime, setSlackRuntime }; diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index 3e0234029ac..cf4eb5a543f 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -2,7 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = - createPluginRuntimeStore( - "Synology Chat runtime not initialized - plugin not registered", - ); + createPluginRuntimeStore({ + pluginId: "synology-chat", + errorMessage: "Synology Chat runtime not initialized - plugin not registered", + }); export { getSynologyRuntime, setSynologyRuntime }; diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index e9f875d27ad..2a7e99dbfdb 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -6,5 +6,8 @@ const { setRuntime: setTelegramRuntime, clearRuntime: clearTelegramRuntime, getRuntime: getTelegramRuntime, -} = createPluginRuntimeStore("Telegram runtime not initialized"); +} = createPluginRuntimeStore({ + pluginId: "telegram", + errorMessage: "Telegram runtime not initialized", +}); export { clearTelegramRuntime, getTelegramRuntime, setTelegramRuntime }; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index 8544684dc14..12aa5b21ab0 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = - createPluginRuntimeStore("Tlon runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "tlon", + errorMessage: "Tlon runtime not initialized", + }); export { getTlonRuntime, setTlonRuntime }; diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index 916512cc8d4..fa7573081dc 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = - createPluginRuntimeStore("Twitch runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "twitch", + errorMessage: "Twitch runtime not initialized", + }); export { getTwitchRuntime, setTwitchRuntime }; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 8fc8b9e7ed9..9bbf071298b 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = - createPluginRuntimeStore("WhatsApp runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "whatsapp", + errorMessage: "WhatsApp runtime not initialized", + }); export { getWhatsAppRuntime, setWhatsAppRuntime }; diff --git a/extensions/zalo/src/runtime.ts b/extensions/zalo/src/runtime.ts index 919a7681caa..c86489867a7 100644 --- a/extensions/zalo/src/runtime.ts +++ b/extensions/zalo/src/runtime.ts @@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "./runtime-support.js"; const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = - createPluginRuntimeStore("Zalo runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "zalo", + errorMessage: "Zalo runtime not initialized", + }); export { getZaloRuntime, setZaloRuntime }; diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts index cbdf35a8f5c..1acefdfb9bd 100644 --- a/extensions/zalouser/src/runtime.ts +++ b/extensions/zalouser/src/runtime.ts @@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } = - createPluginRuntimeStore("Zalouser runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "zalouser", + errorMessage: "Zalouser runtime not initialized", + }); export { getZalouserRuntime, setZalouserRuntime }; diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 729845cc939..3235457d3a2 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -77,6 +77,94 @@ describe("bundled channel entry shape guards", () => { ).not.toThrow(); }); + it("uses the active bundled plugin root override for channel entry loading", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-override-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginDir = path.join(tempRoot, "dist", "extensions", "alpha"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + "globalThis.__bundledOverrideRuntime = undefined;", + "const plugin = { id: 'alpha', meta: {}, capabilities: {}, config: {} };", + "export default {", + " kind: 'bundled-channel-entry',", + " id: 'alpha',", + " name: 'Alpha',", + " description: 'Alpha',", + " register() {},", + " loadChannelPlugin() { return plugin; },", + " setChannelRuntime(runtime) { globalThis.__bundledOverrideRuntime = runtime.marker; },", + "};", + "", + ].join("\n"), + "utf8", + ); + + let metadataRootDir: string | undefined; + let generatedRootDir: string | undefined; + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: (params?: { rootDir?: string }) => { + metadataRootDir = params?.rootDir; + return [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + }, + ]; + }, + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + ) => { + generatedRootDir = rootDir; + return path.join( + rootDir, + "dist", + "extensions", + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ); + }, + })); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(tempRoot, "dist", "extensions"); + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-override-root", + ); + + bundled.setBundledChannelRuntime("alpha", { marker: "ok" } as never); + const testGlobal = globalThis as typeof globalThis & { + __bundledOverrideRuntime?: unknown; + }; + + expect(metadataRootDir).toBe(tempRoot); + expect(generatedRootDir).toBe(tempRoot); + expect(testGlobal.__bundledOverrideRuntime).toBe("ok"); + expect(bundled.requireBundledChannelPlugin("alpha").id).toBe("alpha"); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + delete (globalThis as { __bundledOverrideRuntime?: unknown }).__bundledOverrideRuntime; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders: string[] = []; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 4eb940c013d..7ec03713633 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -8,6 +8,7 @@ import { resolveBundledChannelGeneratedPath, type BundledChannelPluginMetadata, } from "../../plugins/bundled-channel-runtime.js"; +import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js"; @@ -52,6 +53,27 @@ const OPENCLAW_PACKAGE_ROOT = ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) : process.cwd()); +function derivePackageRootFromBundledPluginsDir(pluginsDir: string): string { + const resolvedDir = path.resolve(pluginsDir); + if (path.basename(resolvedDir) !== "extensions") { + return resolvedDir; + } + const parentDir = path.dirname(resolvedDir); + const parentBase = path.basename(parentDir); + if (parentBase === "dist" || parentBase === "dist-runtime") { + return path.dirname(parentDir); + } + return parentDir; +} + +function resolveBundledChannelPackageRoot(): string { + const bundledPluginsDir = resolveBundledPluginsDir(process.env); + if (bundledPluginsDir) { + return derivePackageRootFromBundledPluginsDir(bundledPluginsDir); + } + return OPENCLAW_PACKAGE_ROOT; +} + function resolveChannelPluginModuleEntry( moduleExport: unknown, ): BundledChannelEntryRuntimeContract | null { @@ -103,16 +125,12 @@ function resolveBundledChannelBoundaryRoot(params: { metadata: BundledChannelPluginMetadata; modulePath: string; }): string { - const distRoot = path.resolve( - OPENCLAW_PACKAGE_ROOT, - "dist", - "extensions", - params.metadata.dirName, - ); + const packageRoot = resolveBundledChannelPackageRoot(); + const distRoot = path.resolve(packageRoot, "dist", "extensions", params.metadata.dirName); if (params.modulePath === distRoot || params.modulePath.startsWith(`${distRoot}${path.sep}`)) { return distRoot; } - return path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.metadata.dirName); + return path.resolve(packageRoot, "extensions", params.metadata.dirName); } function resolveGeneratedBundledChannelModulePath(params: { @@ -122,8 +140,9 @@ function resolveGeneratedBundledChannelModulePath(params: { if (!params.entry) { return null; } + const packageRoot = resolveBundledChannelPackageRoot(); const resolved = resolveBundledChannelGeneratedPath( - OPENCLAW_PACKAGE_ROOT, + packageRoot, params.entry, params.metadata.dirName, ); @@ -194,14 +213,21 @@ function loadGeneratedBundledChannelEntry(params: { } } -let cachedBundledChannelMetadata: readonly BundledChannelPluginMetadata[] | null = null; +const cachedBundledChannelMetadata = new Map(); function listBundledChannelMetadata(): readonly BundledChannelPluginMetadata[] { - cachedBundledChannelMetadata ??= listBundledChannelPluginMetadata({ + const packageRoot = resolveBundledChannelPackageRoot(); + const cached = cachedBundledChannelMetadata.get(packageRoot); + if (cached) { + return cached; + } + const loaded = listBundledChannelPluginMetadata({ + rootDir: packageRoot, includeChannelConfigs: false, includeSyntheticChannelConfigs: false, }).filter((metadata) => (metadata.manifest.channels?.length ?? 0) > 0); - return cachedBundledChannelMetadata; + cachedBundledChannelMetadata.set(packageRoot, loaded); + return loaded; } export function listBundledChannelPluginIds(): readonly ChannelId[] { diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index e75c8a49588..ce26257730d 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -44,6 +44,7 @@ type DefineBundledChannelSetupEntryOptions = { importMetaUrl: string; plugin: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; + runtime?: BundledEntryModuleRef; features?: BundledChannelSetupEntryFeatures; }; @@ -68,6 +69,7 @@ export type BundledChannelSetupEntryContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => TPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + setChannelRuntime?: (runtime: PluginRuntime) => void; features?: BundledChannelSetupEntryFeatures; }; @@ -380,8 +382,21 @@ export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, + runtime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract { + // Bundled setup entries stay on a light path during setup-only/setup-runtime loads. + // When runtime wiring is needed, expose only the setter so the loader can hand + // the setup surface the active runtime without importing the full channel entry. + const setChannelRuntime = runtime + ? (pluginRuntime: PluginRuntime) => { + const setter = loadBundledEntryExportSync<(runtime: PluginRuntime) => void>( + importMetaUrl, + runtime, + ); + setter(pluginRuntime); + } + : undefined; return { kind: "bundled-channel-setup-entry", loadSetupPlugin: () => loadBundledEntryExportSync(importMetaUrl, plugin), @@ -394,6 +409,7 @@ export function defineBundledChannelSetupEntry({ ), } : {}), + ...(setChannelRuntime ? { setChannelRuntime } : {}), ...(features ? { features } : {}), }; } diff --git a/src/plugin-sdk/runtime-store.test.ts b/src/plugin-sdk/runtime-store.test.ts new file mode 100644 index 00000000000..08e0cf1ad45 --- /dev/null +++ b/src/plugin-sdk/runtime-store.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.ts"; +import { createPluginRuntimeStore } from "./runtime-store.js"; + +describe("createPluginRuntimeStore", () => { + test("shares runtime slots for the same plugin id", () => { + const firstStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "shared-plugin", + errorMessage: "shared plugin runtime not initialized", + }); + const secondStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "shared-plugin", + errorMessage: "shared plugin runtime not initialized", + }); + + firstStore.clearRuntime(); + firstStore.setRuntime({ value: "ok" }); + + expect(secondStore.getRuntime()).toEqual({ value: "ok" }); + + secondStore.clearRuntime(); + expect(firstStore.tryGetRuntime()).toBeNull(); + }); + + test("keeps different plugin ids isolated", () => { + const leftStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "left-plugin", + errorMessage: "left runtime not initialized", + }); + const rightStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "right-plugin", + errorMessage: "right runtime not initialized", + }); + + leftStore.clearRuntime(); + rightStore.clearRuntime(); + leftStore.setRuntime({ value: "left" }); + + expect(leftStore.getRuntime()).toEqual({ value: "left" }); + expect(rightStore.tryGetRuntime()).toBeNull(); + }); + + test("keeps legacy string callers working", () => { + const firstStore = createPluginRuntimeStore<{ value: string }>( + "legacy runtime not initialized", + ); + const secondStore = createPluginRuntimeStore<{ value: string }>( + "legacy runtime not initialized", + ); + + firstStore.clearRuntime(); + firstStore.setRuntime({ value: "legacy" }); + + expect(secondStore.getRuntime()).toEqual({ value: "legacy" }); + }); + + test("still supports explicit custom store keys", () => { + const firstStore = createPluginRuntimeStore<{ value: string }>({ + key: "custom-runtime-key", + errorMessage: "custom runtime not initialized", + }); + const secondStore = createPluginRuntimeStore<{ value: string }>({ + key: "custom-runtime-key", + errorMessage: "custom runtime not initialized", + }); + + firstStore.clearRuntime(); + firstStore.setRuntime({ value: "custom" }); + + expect(secondStore.getRuntime()).toEqual({ value: "custom" }); + }); + + test("shares runtime slots across duplicate module instances when plugin id matches", async () => { + const firstModule = await importFreshModule( + import.meta.url, + "./runtime-store.js?scope=runtime-store-a", + ); + const secondModule = await importFreshModule( + import.meta.url, + "./runtime-store.js?scope=runtime-store-b", + ); + const firstStore = firstModule.createPluginRuntimeStore<{ value: string }>({ + pluginId: "duplicate-module-plugin", + errorMessage: "duplicate module runtime not initialized", + }); + const secondStore = secondModule.createPluginRuntimeStore<{ value: string }>({ + pluginId: "duplicate-module-plugin", + errorMessage: "duplicate module runtime not initialized", + }); + + firstStore.clearRuntime(); + firstStore.setRuntime({ value: "shared" }); + + expect(secondStore.getRuntime()).toEqual({ value: "shared" }); + }); +}); diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts index 34257c918b0..ddaf8c5ef15 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -1,29 +1,87 @@ export type { PluginRuntime } from "../plugins/runtime/types.js"; +const pluginRuntimeStoreRegistryKey = Symbol.for("openclaw.plugin-sdk.runtime-store-registry"); + +type PluginRuntimeStoreRegistry = Map; +type PluginRuntimeStoreKeyOptions = { + key: string; + errorMessage: string; +}; +type PluginRuntimeStorePluginOptions = { + pluginId: string; + errorMessage: string; +}; +type PluginRuntimeStoreOptions = PluginRuntimeStoreKeyOptions | PluginRuntimeStorePluginOptions; + +function getPluginRuntimeStoreRegistry(): PluginRuntimeStoreRegistry { + const globalRecord = globalThis as typeof globalThis & { + [pluginRuntimeStoreRegistryKey]?: PluginRuntimeStoreRegistry; + }; + globalRecord[pluginRuntimeStoreRegistryKey] ??= new Map(); + return globalRecord[pluginRuntimeStoreRegistryKey]; +} + +function pluginRuntimeStoreKeyForPluginId(pluginId: string): string { + return `plugin-runtime:${pluginId.trim()}`; +} + +function resolvePluginRuntimeStoreOptions( + options: string | PluginRuntimeStoreOptions, +): PluginRuntimeStoreKeyOptions { + if (typeof options === "string") { + return { key: options, errorMessage: options }; + } + if ("pluginId" in options) { + return { + key: pluginRuntimeStoreKeyForPluginId(options.pluginId), + errorMessage: options.errorMessage, + }; + } + return options; +} + /** Create a tiny mutable runtime slot with strict access when the runtime has not been initialized. */ export function createPluginRuntimeStore(errorMessage: string): { setRuntime: (next: T) => void; clearRuntime: () => void; tryGetRuntime: () => T | null; getRuntime: () => T; +}; +export function createPluginRuntimeStore(options: PluginRuntimeStoreOptions): { + setRuntime: (next: T) => void; + clearRuntime: () => void; + tryGetRuntime: () => T | null; + getRuntime: () => T; +}; +export function createPluginRuntimeStore(options: string | PluginRuntimeStoreOptions): { + setRuntime: (next: T) => void; + clearRuntime: () => void; + tryGetRuntime: () => T | null; + getRuntime: () => T; } { - let runtime: T | null = null; + const resolved = resolvePluginRuntimeStoreOptions(options); + const registry = getPluginRuntimeStoreRegistry(); + let slot = registry.get(resolved.key); + if (!slot) { + slot = { runtime: null }; + registry.set(resolved.key, slot); + } return { setRuntime(next: T) { - runtime = next; + slot.runtime = next; }, clearRuntime() { - runtime = null; + slot.runtime = null; }, tryGetRuntime() { - return runtime; + return (slot.runtime as T | null) ?? null; }, getRuntime() { - if (!runtime) { - throw new Error(errorMessage); + if (!slot.runtime) { + throw new Error(resolved.errorMessage); } - return runtime; + return slot.runtime as T; }, }; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d6e761934d5..e96c2a877bd 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -522,8 +522,11 @@ function createSetupEntryChannelPluginFixture(params: { setupBlurb: string; configured: boolean; startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; + useBundledFullEntryContract?: boolean; useBundledSetupEntryContract?: boolean; splitBundledSetupSecrets?: boolean; + bundledSetupRuntimeMarker?: string; + bundledFullRuntimeMarker?: string; }) { useNoBundledPlugins(); const pluginDir = makeTempDir(); @@ -571,7 +574,39 @@ function createSetupEntryChannelPluginFixture(params: { ); fs.writeFileSync( path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); + params.useBundledFullEntryContract + ? `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + kind: "bundled-channel-entry", + id: ${JSON.stringify(params.id)}, + name: ${JSON.stringify(params.label)}, + description: ${JSON.stringify(params.fullBlurb)}, + loadChannelPlugin: () => ({ + id: ${JSON.stringify(params.id)}, + meta: { + id: ${JSON.stringify(params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + blurb: ${JSON.stringify(params.fullBlurb)}, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ${listAccountIds}, + resolveAccount: () => ${resolveAccount}, + }, + outbound: { deliveryMode: "direct" }, + }), + ${ + params.bundledFullRuntimeMarker + ? `setChannelRuntime: () => { + require("node:fs").writeFileSync(${JSON.stringify(params.bundledFullRuntimeMarker)}, "loaded", "utf-8"); + },` + : "" + } + register() {}, +};` + : `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); module.exports = { id: ${JSON.stringify(params.id)}, register(api) { @@ -631,6 +666,13 @@ module.exports = { }),` : "" } + ${ + params.bundledSetupRuntimeMarker + ? `setChannelRuntime: () => { + require("node:fs").writeFileSync(${JSON.stringify(params.bundledSetupRuntimeMarker)}, "loaded", "utf-8"); + },` + : "" + } };` : `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); module.exports = { @@ -3268,7 +3310,7 @@ module.exports = { }, }, }), - expectFullLoaded: false, + expectFullLoaded: true, expectSetupLoaded: true, expectedChannels: 1, }, @@ -3294,11 +3336,66 @@ module.exports = { }, }, }), - expectFullLoaded: false, + expectFullLoaded: true, expectSetupLoaded: true, expectedChannels: 1, expectedSetupSecretId: "channels.setup-runtime-bundled-contract-secrets-test.setup-token", }, + { + name: "applies bundled setupEntry runtime setter for setup-runtime channel loads", + fixture: { + id: "setup-runtime-bundled-contract-runtime-test", + label: "Setup Runtime Bundled Contract Runtime Test", + packageName: "@openclaw/setup-runtime-bundled-contract-runtime-test", + fullBlurb: "full entry should not run while unconfigured", + setupBlurb: "setup runtime bundled contract runtime", + configured: false, + useBundledSetupEntryContract: true, + bundledSetupRuntimeMarker: path.join(makeTempDir(), "setup-runtime-applied.txt"), + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-bundled-contract-runtime-test"], + }, + }, + }), + expectFullLoaded: true, + expectSetupLoaded: true, + expectedChannels: 1, + expectSetupRuntimeLoaded: true, + }, + { + name: "merges bundled runtime plugin into setup-runtime channel loads", + fixture: { + id: "setup-runtime-bundled-runtime-merge-test", + label: "Setup Runtime Bundled Runtime Merge Test", + packageName: "@openclaw/setup-runtime-bundled-runtime-merge-test", + fullBlurb: "full runtime plugin", + setupBlurb: "setup runtime override", + configured: false, + useBundledFullEntryContract: true, + useBundledSetupEntryContract: true, + bundledFullRuntimeMarker: path.join(makeTempDir(), "bundled-runtime-applied.txt"), + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-bundled-runtime-merge-test"], + }, + }, + }), + expectFullLoaded: true, + expectSetupLoaded: true, + expectedChannels: 1, + expectBundledFullRuntimeLoaded: true, + }, { name: "does not prefer setupEntry for configured channel loads without startup opt-in", fixture: { @@ -3339,6 +3436,8 @@ module.exports = { expectSetupLoaded, expectedChannels, expectedSetupSecretId, + expectSetupRuntimeLoaded, + expectBundledFullRuntimeLoaded, }) => { const built = createSetupEntryChannelPluginFixture(fixture); const registry = load({ pluginDir: built.pluginDir }); @@ -3347,6 +3446,16 @@ module.exports = { expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded); expect(registry.channelSetups).toHaveLength(1); expect(registry.channels).toHaveLength(expectedChannels); + if (fixture.bundledSetupRuntimeMarker) { + expect(fs.existsSync(fixture.bundledSetupRuntimeMarker)).toBe( + expectSetupRuntimeLoaded ?? false, + ); + } + if (fixture.bundledFullRuntimeMarker) { + expect(fs.existsSync(fixture.bundledFullRuntimeMarker)).toBe( + expectBundledFullRuntimeLoaded ?? false, + ); + } if (expectedSetupSecretId) { expect(registry.channelSetups[0]?.plugin.secrets?.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c821f7ede85..1defa0c9ca3 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -638,15 +638,20 @@ function resolvePluginModuleExport(moduleExport: unknown): { return {}; } -function mergeSetupPluginSection( +function mergeChannelPluginSection( baseValue: T | undefined, - setupValue: T | undefined, + overrideValue: T | undefined, ): T | undefined { - if (baseValue && setupValue && typeof baseValue === "object" && typeof setupValue === "object") { + if ( + baseValue && + overrideValue && + typeof baseValue === "object" && + typeof overrideValue === "object" + ) { const merged = { ...(baseValue as Record), }; - for (const [key, value] of Object.entries(setupValue as Record)) { + for (const [key, value] of Object.entries(overrideValue as Record)) { if (value !== undefined) { merged[key] = value; } @@ -655,11 +660,83 @@ function mergeSetupPluginSection( ...merged, } as T; } - return setupValue ?? baseValue; + return overrideValue ?? baseValue; +} + +function mergeSetupRuntimeChannelPlugin( + runtimePlugin: ChannelPlugin, + setupPlugin: ChannelPlugin, +): ChannelPlugin { + return { + ...runtimePlugin, + ...setupPlugin, + meta: mergeChannelPluginSection(runtimePlugin.meta, setupPlugin.meta), + capabilities: mergeChannelPluginSection(runtimePlugin.capabilities, setupPlugin.capabilities), + commands: mergeChannelPluginSection(runtimePlugin.commands, setupPlugin.commands), + doctor: mergeChannelPluginSection(runtimePlugin.doctor, setupPlugin.doctor), + reload: mergeChannelPluginSection(runtimePlugin.reload, setupPlugin.reload), + config: mergeChannelPluginSection(runtimePlugin.config, setupPlugin.config), + setup: mergeChannelPluginSection(runtimePlugin.setup, setupPlugin.setup), + messaging: mergeChannelPluginSection(runtimePlugin.messaging, setupPlugin.messaging), + actions: mergeChannelPluginSection(runtimePlugin.actions, setupPlugin.actions), + secrets: mergeChannelPluginSection(runtimePlugin.secrets, setupPlugin.secrets), + } as ChannelPlugin; +} + +function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { + plugin?: ChannelPlugin; + setChannelRuntime?: (runtime: PluginRuntime) => void; + loadError?: unknown; +} { + const resolved = unwrapDefaultModuleExport(moduleExport); + if (!resolved || typeof resolved !== "object") { + return {}; + } + const entryRecord = resolved as { + kind?: unknown; + loadChannelPlugin?: unknown; + loadChannelSecrets?: unknown; + setChannelRuntime?: unknown; + }; + if ( + entryRecord.kind !== "bundled-channel-entry" || + typeof entryRecord.loadChannelPlugin !== "function" + ) { + return {}; + } + try { + const loadedPlugin = entryRecord.loadChannelPlugin(); + const loadedSecrets = + typeof entryRecord.loadChannelSecrets === "function" + ? (entryRecord.loadChannelSecrets() as ChannelPlugin["secrets"] | undefined) + : undefined; + if (loadedPlugin && typeof loadedPlugin === "object") { + const mergedSecrets = mergeChannelPluginSection( + (loadedPlugin as ChannelPlugin).secrets, + loadedSecrets, + ); + return { + plugin: { + ...(loadedPlugin as ChannelPlugin), + ...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}), + }, + ...(typeof entryRecord.setChannelRuntime === "function" + ? { + setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void, + } + : {}), + }; + } + } catch (err) { + return { loadError: err }; + } + return {}; } function resolveSetupChannelRegistration(moduleExport: unknown): { plugin?: ChannelPlugin; + setChannelRuntime?: (runtime: PluginRuntime) => void; + usesBundledSetupContract?: boolean; loadError?: unknown; } { const resolved = unwrapDefaultModuleExport(moduleExport); @@ -670,6 +747,7 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { kind?: unknown; loadSetupPlugin?: unknown; loadSetupSecrets?: unknown; + setChannelRuntime?: unknown; }; if ( setupEntryRecord.kind === "bundled-channel-setup-entry" && @@ -682,7 +760,7 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { ? (setupEntryRecord.loadSetupSecrets() as ChannelPlugin["secrets"] | undefined) : undefined; if (loadedPlugin && typeof loadedPlugin === "object") { - const mergedSecrets = mergeSetupPluginSection( + const mergedSecrets = mergeChannelPluginSection( (loadedPlugin as ChannelPlugin).secrets, loadedSecrets, ); @@ -691,6 +769,14 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { ...(loadedPlugin as ChannelPlugin), ...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}), }, + usesBundledSetupContract: true, + ...(typeof setupEntryRecord.setChannelRuntime === "function" + ? { + setChannelRuntime: setupEntryRecord.setChannelRuntime as ( + runtime: PluginRuntime, + ) => void, + } + : {}), }; } } catch (err) { @@ -1697,9 +1783,81 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } if (setupRegistration.plugin) { - if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { + let mergedSetupRegistration = setupRegistration; + if (setupRegistration.usesBundledSetupContract && candidate.source !== safeSource) { + const runtimeOpened = openBoundaryFileSync({ + absolutePath: candidate.source, + rootPath: pluginRoot, + boundaryLabel: "plugin root", + rejectHardlinks: candidate.origin !== "bundled", + skipLexicalRootCheck: true, + }); + if (!runtimeOpened.ok) { + pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks"); + continue; + } + const safeRuntimeSource = runtimeOpened.path; + fs.closeSync(runtimeOpened.fd); + const safeRuntimeImportSource = toSafeImportPath(safeRuntimeSource); + let runtimeMod: OpenClawPluginModule | null = null; + try { + runtimeMod = profilePluginLoaderSync({ + phase: "load-setup-runtime-entry", + pluginId: record.id, + source: safeRuntimeSource, + run: () => + getJiti(safeRuntimeSource)(safeRuntimeImportSource) as OpenClawPluginModule, + }); + } catch (err) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: err, + logPrefix: `[plugins] ${record.id} failed to load setup-runtime entry from ${record.source}: `, + diagnosticMessagePrefix: "failed to load setup-runtime entry: ", + }); + continue; + } + const runtimeRegistration = resolveBundledRuntimeChannelRegistration(runtimeMod); + if (runtimeRegistration.loadError) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: runtimeRegistration.loadError, + logPrefix: `[plugins] ${record.id} failed to load setup-runtime channel entry from ${record.source}: `, + diagnosticMessagePrefix: "failed to load setup-runtime channel entry: ", + }); + continue; + } + if (runtimeRegistration.plugin) { + mergedSetupRegistration = { + ...setupRegistration, + plugin: mergeSetupRuntimeChannelPlugin( + runtimeRegistration.plugin, + setupRegistration.plugin, + ), + setChannelRuntime: + runtimeRegistration.setChannelRuntime ?? setupRegistration.setChannelRuntime, + }; + } + } + const mergedSetupPlugin = mergedSetupRegistration.plugin; + if (!mergedSetupPlugin) { + continue; + } + if (mergedSetupPlugin.id && mergedSetupPlugin.id !== record.id) { pushPluginLoadError( - `plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`, + `plugin id mismatch (config uses "${record.id}", setup export uses "${mergedSetupPlugin.id}")`, ); continue; } @@ -1709,7 +1867,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); - api.registerChannel(setupRegistration.plugin); + mergedSetupRegistration.setChannelRuntime?.(api.runtime); + api.registerChannel(mergedSetupPlugin); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue;