diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f69192e774..de167ff0be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559. - Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792. - Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator. +- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras. ## 2026.4.15-beta.1 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/bootstrap-registry.ts b/src/channels/plugins/bootstrap-registry.ts index 33efc11ab97..93cf31d5431 100644 --- a/src/channels/plugins/bootstrap-registry.ts +++ b/src/channels/plugins/bootstrap-registry.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; -import { listBundledChannelPluginIds } from "./bundled-ids.js"; +import { listBundledChannelPluginIdsForRoot } from "./bundled-ids.js"; +import { resolveBundledChannelRootScope } from "./bundled-root.js"; import { getBundledChannelPlugin, getBundledChannelSecrets, @@ -16,7 +17,11 @@ type CachedBootstrapPlugins = { missingIds: Set; }; -let cachedBootstrapPlugins: CachedBootstrapPlugins | null = null; +const cachedBootstrapPluginsByRoot = new Map(); + +function resolveBootstrapChannelId(id: ChannelId): string { + return normalizeOptionalString(id) ?? ""; +} function mergePluginSection( runtimeValue: T | undefined, @@ -63,22 +68,37 @@ function mergeBootstrapPlugin( } as ChannelPlugin; } -function buildBootstrapPlugins(): CachedBootstrapPlugins { +function buildBootstrapPlugins( + cacheKey: string, + env: NodeJS.ProcessEnv = process.env, +): CachedBootstrapPlugins { return { - sortedIds: listBundledChannelPluginIds(), + sortedIds: listBundledChannelPluginIdsForRoot(cacheKey, env), byId: new Map(), secretsById: new Map(), missingIds: new Set(), }; } -function getBootstrapPlugins(): CachedBootstrapPlugins { - cachedBootstrapPlugins ??= buildBootstrapPlugins(); - return cachedBootstrapPlugins; +function getBootstrapPlugins( + cacheKey = resolveBundledChannelRootScope().cacheKey, + env: NodeJS.ProcessEnv = process.env, +): CachedBootstrapPlugins { + const cached = cachedBootstrapPluginsByRoot.get(cacheKey); + if (cached) { + return cached; + } + const created = buildBootstrapPlugins(cacheKey, env); + cachedBootstrapPluginsByRoot.set(cacheKey, created); + return created; +} + +function resolveActiveBootstrapPlugins(): CachedBootstrapPlugins { + return getBootstrapPlugins(resolveBundledChannelRootScope().cacheKey); } export function listBootstrapChannelPluginIds(): readonly string[] { - return getBootstrapPlugins().sortedIds; + return resolveActiveBootstrapPlugins().sortedIds; } export function* iterateBootstrapChannelPlugins(): IterableIterator { @@ -95,11 +115,11 @@ export function listBootstrapChannelPlugins(): readonly ChannelPlugin[] { } export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - const resolvedId = normalizeOptionalString(id) ?? ""; + const resolvedId = resolveBootstrapChannelId(id); if (!resolvedId) { return undefined; } - const registry = getBootstrapPlugins(); + const registry = resolveActiveBootstrapPlugins(); const cached = registry.byId.get(resolvedId); if (cached) { return cached; @@ -122,11 +142,11 @@ export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefi } export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const resolvedId = normalizeOptionalString(id) ?? ""; + const resolvedId = resolveBootstrapChannelId(id); if (!resolvedId) { return undefined; } - const registry = getBootstrapPlugins(); + const registry = resolveActiveBootstrapPlugins(); const cached = registry.secretsById.get(resolvedId); if (cached) { return cached; @@ -142,5 +162,5 @@ export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secret } export function clearBootstrapChannelPluginCache(): void { - cachedBootstrapPlugins = null; + cachedBootstrapPluginsByRoot.clear(); } diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index 7edbf2ded49..f801856179b 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -1,10 +1,23 @@ import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; +import { resolveBundledChannelRootScope } from "./bundled-root.js"; -let bundledChannelPluginIds: string[] | null = null; +const bundledChannelPluginIdsByRoot = new Map(); -export function listBundledChannelPluginIds(): string[] { - bundledChannelPluginIds ??= listChannelCatalogEntries({ origin: "bundled" }) +export function listBundledChannelPluginIdsForRoot( + packageRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const cached = bundledChannelPluginIdsByRoot.get(packageRoot); + if (cached) { + return [...cached]; + } + const loaded = listChannelCatalogEntries({ origin: "bundled", env }) .map((entry) => entry.pluginId) .toSorted((left, right) => left.localeCompare(right)); - return [...bundledChannelPluginIds]; + bundledChannelPluginIdsByRoot.set(packageRoot, loaded); + return [...loaded]; +} + +export function listBundledChannelPluginIds(): string[] { + return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope().cacheKey); } diff --git a/src/channels/plugins/bundled-root-caches.test.ts b/src/channels/plugins/bundled-root-caches.test.ts new file mode 100644 index 00000000000..704d3a1255e --- /dev/null +++ b/src/channels/plugins/bundled-root-caches.test.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../../test/helpers/import-fresh.ts"; + +const tempDirs: string[] = []; +const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + +function makeBundledRoot(prefix: string): { root: string; pluginsDir: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(root); + const pluginsDir = path.join(root, "dist", "extensions"); + fs.mkdirSync(pluginsDir, { recursive: true }); + return { root, pluginsDir }; +} + +function resolveMockRootSuffix(params: { + activeRoot: string | undefined; + rootAPluginsDir: string; + rootBPluginsDir: string; +}): "A" | "B" | "unknown" { + if (params.activeRoot === params.rootAPluginsDir) { + return "A"; + } + if (params.activeRoot === params.rootBPluginsDir) { + return "B"; + } + return "unknown"; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; + } + vi.resetModules(); + vi.doUnmock("../../plugins/channel-catalog-registry.js"); + vi.doUnmock("./bundled.js"); + vi.doUnmock("./bundled-ids.js"); +}); + +describe("bundled root-aware caches", () => { + it("partitions bundled channel ids by active bundled root without re-importing", async () => { + const rootA = makeBundledRoot("openclaw-bundled-ids-a-"); + const rootB = makeBundledRoot("openclaw-bundled-ids-b-"); + + vi.doMock("../../plugins/channel-catalog-registry.js", () => ({ + listChannelCatalogEntries: (params?: { env?: NodeJS.ProcessEnv }) => { + const activeRoot = params?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR; + if (activeRoot === rootA.pluginsDir) { + return [{ pluginId: "alpha" }]; + } + if (activeRoot === rootB.pluginsDir) { + return [{ pluginId: "beta" }]; + } + return []; + }, + })); + + const bundledIds = await importFreshModule( + import.meta.url, + "./bundled-ids.js?scope=root-aware-id-cache", + ); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootA.pluginsDir; + expect(bundledIds.listBundledChannelPluginIds()).toEqual(["alpha"]); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootB.pluginsDir; + expect(bundledIds.listBundledChannelPluginIds()).toEqual(["beta"]); + }); + + it("partitions bootstrap plugin caches by active bundled root without re-importing", async () => { + const rootA = makeBundledRoot("openclaw-bootstrap-a-"); + const rootB = makeBundledRoot("openclaw-bootstrap-b-"); + + vi.doMock("./bundled-ids.js", () => ({ + listBundledChannelPluginIdsForRoot: (cacheKey: string) => { + if (cacheKey === rootA.pluginsDir) { + return ["alpha"]; + } + if (cacheKey === rootB.pluginsDir) { + return ["beta"]; + } + return []; + }, + })); + + vi.doMock("./bundled.js", () => ({ + getBundledChannelPlugin: (id: string) => ({ + id, + meta: { id, label: `runtime-${id}` }, + capabilities: {}, + config: {}, + }), + getBundledChannelSetupPlugin: (id: string) => { + const suffix = resolveMockRootSuffix({ + activeRoot: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR, + rootAPluginsDir: rootA.pluginsDir, + rootBPluginsDir: rootB.pluginsDir, + }); + return { + id, + meta: { id, label: `setup-${suffix}` }, + capabilities: {}, + config: {}, + }; + }, + getBundledChannelSecrets: (id: string) => ({ + secretTargetRegistryEntries: [{ id: `runtime-${id}`, targetType: "channel" }], + }), + getBundledChannelSetupSecrets: (id: string) => { + const suffix = resolveMockRootSuffix({ + activeRoot: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR, + rootAPluginsDir: rootA.pluginsDir, + rootBPluginsDir: rootB.pluginsDir, + }); + return { + secretTargetRegistryEntries: [{ id: `setup-${id}-${suffix}`, targetType: "channel" }], + }; + }, + })); + + const bootstrapRegistry = await importFreshModule( + import.meta.url, + "./bootstrap-registry.js?scope=root-aware-bootstrap-cache", + ); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootA.pluginsDir; + expect(bootstrapRegistry.listBootstrapChannelPluginIds()).toEqual(["alpha"]); + expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")?.meta.label).toBe("setup-A"); + expect( + bootstrapRegistry.getBootstrapChannelSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("setup-alpha-A"); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootB.pluginsDir; + expect(bootstrapRegistry.listBootstrapChannelPluginIds()).toEqual(["beta"]); + expect(bootstrapRegistry.getBootstrapChannelPlugin("beta")?.meta.label).toBe("setup-B"); + expect( + bootstrapRegistry.getBootstrapChannelSecrets("beta")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("setup-beta-B"); + }); +}); diff --git a/src/channels/plugins/bundled-root.ts b/src/channels/plugins/bundled-root.ts new file mode 100644 index 00000000000..0e6002b9ba0 --- /dev/null +++ b/src/channels/plugins/bundled-root.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; +import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; + +const OPENCLAW_PACKAGE_ROOT = + resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url.startsWith("file:") ? import.meta.url : undefined, + }) ?? + (import.meta.url.startsWith("file:") + ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) + : process.cwd()); + +export type BundledChannelRootScope = { + packageRoot: string; + cacheKey: string; + pluginsDir?: string; +}; + +function derivePackageRootFromExtensionsDir(extensionsDir: string): string { + const parentDir = path.dirname(extensionsDir); + const parentBase = path.basename(parentDir); + if (parentBase === "dist" || parentBase === "dist-runtime") { + return path.dirname(parentDir); + } + return parentDir; +} + +export function resolveBundledChannelRootScope( + env: NodeJS.ProcessEnv = process.env, +): BundledChannelRootScope { + const bundledPluginsDir = resolveBundledPluginsDir(env); + if (!bundledPluginsDir) { + return { + packageRoot: OPENCLAW_PACKAGE_ROOT, + cacheKey: OPENCLAW_PACKAGE_ROOT, + }; + } + const resolvedPluginsDir = path.resolve(bundledPluginsDir); + return { + packageRoot: + path.basename(resolvedPluginsDir) === "extensions" + ? derivePackageRootFromExtensionsDir(resolvedPluginsDir) + : resolvedPluginsDir, + cacheKey: resolvedPluginsDir, + pluginsDir: resolvedPluginsDir, + }; +} diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 729845cc939..19f465ce5de 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -77,6 +77,326 @@ 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("treats direct bundled plugin-tree overrides as scan roots", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-direct-override-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginsRoot = path.join(tempRoot, "bundled-plugins"); + const pluginDir = path.join(pluginsRoot, "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 metadataScanDir: string | undefined; + let generatedRootDir: string | undefined; + let generatedScanDir: string | undefined; + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: (params?: { rootDir?: string; scanDir?: string }) => { + metadataScanDir = params?.scanDir; + 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, + scanDir?: string, + ) => { + generatedRootDir = rootDir; + generatedScanDir = scanDir; + return path.join( + scanDir ?? path.join(rootDir, "dist", "extensions"), + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ); + }, + })); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = pluginsRoot; + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-direct-override-root", + ); + + bundled.setBundledChannelRuntime("alpha", { marker: "ok" } as never); + const testGlobal = globalThis as typeof globalThis & { + __bundledOverrideRuntime?: unknown; + }; + + expect(metadataScanDir).toBe(pluginsRoot); + expect(generatedRootDir).toBe(pluginsRoot); + expect(generatedScanDir).toBe(pluginsRoot); + 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("partitions bundled channel lazy caches by active bundled root without re-importing", async () => { + const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-root-a-")); + const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-root-b-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const testGlobal = globalThis as typeof globalThis & { + __bundledRootRuntime?: unknown; + }; + + const writeBundledRoot = (rootDir: string, label: string) => { + const pluginDir = path.join(rootDir, "dist", "extensions", "alpha"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + `globalThis.__bundledRootRuntime = globalThis.__bundledRootRuntime ?? [];`, + "export default {", + " kind: 'bundled-channel-entry',", + " id: 'alpha',", + ` name: ${JSON.stringify(`Alpha ${label}`)},`, + ` description: ${JSON.stringify(`Alpha ${label}`)},`, + " register() {},", + " loadChannelPlugin() {", + " return {", + " id: 'alpha',", + ` meta: { id: 'alpha', label: ${JSON.stringify(`Alpha ${label}`)} },`, + " capabilities: {},", + " config: {},", + ` secrets: { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.token`)}, targetType: 'channel' }] },`, + " };", + " },", + " loadChannelSecrets() {", + ` return { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.entry-token`)}, targetType: 'channel' }] };`, + " },", + " setChannelRuntime(runtime) {", + ` globalThis.__bundledRootRuntime.push(${JSON.stringify(`entry:${label}`)} + ':' + String(runtime.marker));`, + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.js"), + [ + "export default {", + " kind: 'bundled-channel-setup-entry',", + " loadSetupPlugin() {", + " return {", + " id: 'alpha',", + ` meta: { id: 'alpha', label: ${JSON.stringify(`Setup ${label}`)} },`, + " capabilities: {},", + " config: {},", + ` secrets: { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.setup-plugin-token`)}, targetType: 'channel' }] },`, + " };", + " },", + " loadSetupSecrets() {", + ` return { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.setup-entry-token`)}, targetType: 'channel' }] };`, + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + }; + + writeBundledRoot(rootA, "A"); + writeBundledRoot(rootB, "B"); + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: () => [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + setupSource: { + source: "./setup-entry.js", + built: "./setup-entry.js", + }, + }, + ], + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + ) => + path.join( + rootDir, + "dist", + "extensions", + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ), + })); + + try { + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-root-partition", + ); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(rootA, "dist", "extensions"); + expect(bundled.requireBundledChannelPlugin("alpha").meta.label).toBe("Alpha A"); + expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("Setup A"); + expect(bundled.getBundledChannelSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id).toBe( + "channels.alpha.A.entry-token", + ); + expect( + bundled.getBundledChannelSetupSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("channels.alpha.A.setup-entry-token"); + bundled.setBundledChannelRuntime("alpha", { marker: "first" } as never); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(rootB, "dist", "extensions"); + expect(bundled.requireBundledChannelPlugin("alpha").meta.label).toBe("Alpha B"); + expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("Setup B"); + expect(bundled.getBundledChannelSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id).toBe( + "channels.alpha.B.entry-token", + ); + expect( + bundled.getBundledChannelSetupSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("channels.alpha.B.setup-entry-token"); + bundled.setBundledChannelRuntime("alpha", { marker: "second" } as never); + + expect(testGlobal.__bundledRootRuntime).toEqual(["entry:A:first", "entry:B:second"]); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(rootA, { recursive: true, force: true }); + fs.rmSync(rootB, { recursive: true, force: true }); + delete testGlobal.__bundledRootRuntime; + } + }); + 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..e58bb22c446 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,7 +1,5 @@ import path from "node:path"; -import { fileURLToPath } from "node:url"; import { formatErrorMessage } from "../../infra/errors.js"; -import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { listBundledChannelPluginMetadata, @@ -10,6 +8,7 @@ import { } from "../../plugins/bundled-channel-runtime.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; @@ -41,16 +40,18 @@ type GeneratedBundledChannelEntry = { setupEntry?: BundledChannelSetupEntryRuntimeContract; }; +type BundledChannelCacheContext = { + pluginLoadInProgressIds: Set; + setupPluginLoadInProgressIds: Set; + entryLoadInProgressIds: Set; + lazyEntriesById: Map; + lazyPluginsById: Map; + lazySetupPluginsById: Map; + lazySecretsById: Map; + lazySetupSecretsById: Map; +}; + const log = createSubsystemLogger("channels"); -const OPENCLAW_PACKAGE_ROOT = - resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url.startsWith("file:") ? import.meta.url : undefined, - }) ?? - (import.meta.url.startsWith("file:") - ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) - : process.cwd()); function resolveChannelPluginModuleEntry( moduleExport: unknown, @@ -100,40 +101,50 @@ function hasSetupEntryFeature( } function resolveBundledChannelBoundaryRoot(params: { + packageRoot: string; + pluginsDir?: string; metadata: BundledChannelPluginMetadata; modulePath: string; }): string { - const distRoot = path.resolve( - OPENCLAW_PACKAGE_ROOT, - "dist", - "extensions", - params.metadata.dirName, - ); + const overrideRoot = params.pluginsDir + ? path.resolve(params.pluginsDir, params.metadata.dirName) + : null; + if ( + overrideRoot && + (params.modulePath === overrideRoot || + params.modulePath.startsWith(`${overrideRoot}${path.sep}`)) + ) { + return overrideRoot; + } + const distRoot = path.resolve(params.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(params.packageRoot, "extensions", params.metadata.dirName); +} + +function resolveBundledChannelScanDir(rootScope: BundledChannelRootScope): string | undefined { + return rootScope.pluginsDir; } function resolveGeneratedBundledChannelModulePath(params: { + rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): string | null { if (!params.entry) { return null; } - const resolved = resolveBundledChannelGeneratedPath( - OPENCLAW_PACKAGE_ROOT, + return resolveBundledChannelGeneratedPath( + params.rootScope.packageRoot, params.entry, params.metadata.dirName, + resolveBundledChannelScanDir(params.rootScope), ); - if (resolved) { - return resolved; - } - return null; } function loadGeneratedBundledChannelModule(params: { + rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): unknown { @@ -141,28 +152,31 @@ function loadGeneratedBundledChannelModule(params: { if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } + const scanDir = resolveBundledChannelScanDir(params.rootScope); + const boundaryRoot = resolveBundledChannelBoundaryRoot({ + packageRoot: params.rootScope.packageRoot, + ...(scanDir ? { pluginsDir: scanDir } : {}), + metadata: params.metadata, + modulePath, + }); return loadChannelPluginModule({ modulePath, - rootDir: resolveBundledChannelBoundaryRoot({ - metadata: params.metadata, - modulePath, - }), - boundaryRootDir: resolveBundledChannelBoundaryRoot({ - metadata: params.metadata, - modulePath, - }), + rootDir: boundaryRoot, + boundaryRootDir: boundaryRoot, shouldTryNativeRequire: (safePath) => safePath.includes(`${path.sep}dist${path.sep}`) && isJavaScriptModulePath(safePath), }); } function loadGeneratedBundledChannelEntry(params: { + rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; includeSetup: boolean; }): GeneratedBundledChannelEntry | null { try { const entry = resolveChannelPluginModuleEntry( loadGeneratedBundledChannelModule({ + rootScope: params.rootScope, metadata: params.metadata, entry: params.metadata.source, }), @@ -177,6 +191,7 @@ function loadGeneratedBundledChannelEntry(params: { params.includeSetup && params.metadata.setupSource ? resolveChannelSetupModuleEntry( loadGeneratedBundledChannelModule({ + rootScope: params.rootScope, metadata: params.metadata, entry: params.metadata.setupSource, }), @@ -194,82 +209,226 @@ function loadGeneratedBundledChannelEntry(params: { } } -let cachedBundledChannelMetadata: readonly BundledChannelPluginMetadata[] | null = null; +const cachedBundledChannelMetadata = new Map(); +const bundledChannelCacheContexts = new Map(); -function listBundledChannelMetadata(): readonly BundledChannelPluginMetadata[] { - cachedBundledChannelMetadata ??= listBundledChannelPluginMetadata({ +function createBundledChannelCacheContext(): BundledChannelCacheContext { + return { + pluginLoadInProgressIds: new Set(), + setupPluginLoadInProgressIds: new Set(), + entryLoadInProgressIds: new Set(), + lazyEntriesById: new Map(), + lazyPluginsById: new Map(), + lazySetupPluginsById: new Map(), + lazySecretsById: new Map(), + lazySetupSecretsById: new Map(), + }; +} + +function getBundledChannelCacheContext(cacheKey: string): BundledChannelCacheContext { + const cached = bundledChannelCacheContexts.get(cacheKey); + if (cached) { + return cached; + } + const created = createBundledChannelCacheContext(); + bundledChannelCacheContexts.set(cacheKey, created); + return created; +} + +function resolveActiveBundledChannelCacheScope(): { + rootScope: BundledChannelRootScope; + cacheContext: BundledChannelCacheContext; +} { + const rootScope = resolveBundledChannelRootScope(); + return { + rootScope, + cacheContext: getBundledChannelCacheContext(rootScope.cacheKey), + }; +} + +function listBundledChannelMetadata( + rootScope = resolveBundledChannelRootScope(), +): readonly BundledChannelPluginMetadata[] { + const cached = cachedBundledChannelMetadata.get(rootScope.cacheKey); + if (cached) { + return cached; + } + const scanDir = resolveBundledChannelScanDir(rootScope); + const loaded = listBundledChannelPluginMetadata({ + rootDir: rootScope.packageRoot, + ...(scanDir ? { scanDir } : {}), includeChannelConfigs: false, includeSyntheticChannelConfigs: false, }).filter((metadata) => (metadata.manifest.channels?.length ?? 0) > 0); - return cachedBundledChannelMetadata; + cachedBundledChannelMetadata.set(rootScope.cacheKey, loaded); + return loaded; } -export function listBundledChannelPluginIds(): readonly ChannelId[] { - return listBundledChannelMetadata() +function listBundledChannelPluginIdsForRoot( + rootScope: BundledChannelRootScope, +): readonly ChannelId[] { + return listBundledChannelMetadata(rootScope) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); } -const pluginLoadInProgressIds = new Set(); -const setupPluginLoadInProgressIds = new Set(); -const entryLoadInProgressIds = new Set(); -const lazyEntriesById = new Map(); -const lazyPluginsById = new Map(); -const lazySetupPluginsById = new Map(); -const lazySecretsById = new Map(); -const lazySetupSecretsById = new Map(); +export function listBundledChannelPluginIds(): readonly ChannelId[] { + return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope()); +} -function resolveBundledChannelMetadata(id: ChannelId): BundledChannelPluginMetadata | undefined { - return listBundledChannelMetadata().find( +function resolveBundledChannelMetadata( + id: ChannelId, + rootScope: BundledChannelRootScope, +): BundledChannelPluginMetadata | undefined { + return listBundledChannelMetadata(rootScope).find( (metadata) => metadata.manifest.id === id || metadata.manifest.channels?.includes(id), ); } -function getLazyGeneratedBundledChannelEntry( +function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, params?: { includeSetup?: boolean }, ): GeneratedBundledChannelEntry | null { - const cached = lazyEntriesById.get(id); + const cached = cacheContext.lazyEntriesById.get(id); if (cached && (!params?.includeSetup || cached.setupEntry)) { return cached; } if (cached === null && !params?.includeSetup) { return null; } - const metadata = resolveBundledChannelMetadata(id); + const metadata = resolveBundledChannelMetadata(id, rootScope); if (!metadata) { - lazyEntriesById.set(id, null); + cacheContext.lazyEntriesById.set(id, null); return null; } - if (entryLoadInProgressIds.has(id)) { + if (cacheContext.entryLoadInProgressIds.has(id)) { return null; } - entryLoadInProgressIds.add(id); + cacheContext.entryLoadInProgressIds.add(id); try { const entry = loadGeneratedBundledChannelEntry({ + rootScope, metadata, includeSetup: params?.includeSetup === true, }); - lazyEntriesById.set(id, entry); + cacheContext.lazyEntriesById.set(id, entry); if (entry?.entry.id && entry.entry.id !== id) { - lazyEntriesById.set(entry.entry.id, entry); + cacheContext.lazyEntriesById.set(entry.entry.id, entry); } return entry; } finally { - entryLoadInProgressIds.delete(id); + cacheContext.entryLoadInProgressIds.delete(id); } } +function getBundledChannelPluginForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin | undefined { + const cached = cacheContext.lazyPluginsById.get(id); + if (cached) { + return cached; + } + if (cacheContext.pluginLoadInProgressIds.has(id)) { + return undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; + if (!entry) { + return undefined; + } + cacheContext.pluginLoadInProgressIds.add(id); + try { + const plugin = entry.loadChannelPlugin(); + cacheContext.lazyPluginsById.set(id, plugin); + return plugin; + } finally { + cacheContext.pluginLoadInProgressIds.delete(id); + } +} + +function getBundledChannelSecretsForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin["secrets"] | undefined { + if (cacheContext.lazySecretsById.has(id)) { + return cacheContext.lazySecretsById.get(id) ?? undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; + if (!entry) { + return undefined; + } + const secrets = + entry.loadChannelSecrets?.() ?? + getBundledChannelPluginForRoot(id, rootScope, cacheContext)?.secrets; + cacheContext.lazySecretsById.set(id, secrets ?? null); + return secrets; +} + +function getBundledChannelSetupPluginForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin | undefined { + const cached = cacheContext.lazySetupPluginsById.get(id); + if (cached) { + return cached; + } + if (cacheContext.setupPluginLoadInProgressIds.has(id)) { + return undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { + includeSetup: true, + })?.setupEntry; + if (!entry) { + return undefined; + } + cacheContext.setupPluginLoadInProgressIds.add(id); + try { + const plugin = entry.loadSetupPlugin(); + cacheContext.lazySetupPluginsById.set(id, plugin); + return plugin; + } finally { + cacheContext.setupPluginLoadInProgressIds.delete(id); + } +} + +function getBundledChannelSetupSecretsForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin["secrets"] | undefined { + if (cacheContext.lazySetupSecretsById.has(id)) { + return cacheContext.lazySetupSecretsById.get(id) ?? undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { + includeSetup: true, + })?.setupEntry; + if (!entry) { + return undefined; + } + const secrets = + entry.loadSetupSecrets?.() ?? + getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext)?.secrets; + cacheContext.lazySetupSecretsById.set(id, secrets ?? null); + return secrets; +} + export function listBundledChannelPlugins(): readonly ChannelPlugin[] { - return listBundledChannelPluginIds().flatMap((id) => { - const plugin = getBundledChannelPlugin(id); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { + const plugin = getBundledChannelPluginForRoot(id, rootScope, cacheContext); return plugin ? [plugin] : []; }); } export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { - return listBundledChannelPluginIds().flatMap((id) => { - const plugin = getBundledChannelSetupPlugin(id); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); return plugin ? [plugin] : []; }); } @@ -277,84 +436,37 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, ): readonly ChannelPlugin[] { - return listBundledChannelPluginIds().flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { + includeSetup: true, + })?.setupEntry; if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } - const plugin = getBundledChannelSetupPlugin(id); + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); return plugin ? [plugin] : []; }); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - const cached = lazyPluginsById.get(id); - if (cached) { - return cached; - } - if (pluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id)?.entry; - if (!entry) { - return undefined; - } - pluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadChannelPlugin(); - lazyPluginsById.set(id, plugin); - return plugin; - } finally { - pluginLoadInProgressIds.delete(id); - } + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelPluginForRoot(id, rootScope, cacheContext); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - if (lazySecretsById.has(id)) { - return lazySecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id)?.entry; - if (!entry) { - return undefined; - } - const secrets = entry.loadChannelSecrets?.() ?? getBundledChannelPlugin(id)?.secrets; - lazySecretsById.set(id, secrets ?? null); - return secrets; + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelSecretsForRoot(id, rootScope, cacheContext); } export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { - const cached = lazySetupPluginsById.get(id); - if (cached) { - return cached; - } - if (setupPluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; - if (!entry) { - return undefined; - } - setupPluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadSetupPlugin(); - lazySetupPluginsById.set(id, plugin); - return plugin; - } finally { - setupPluginLoadInProgressIds.delete(id); - } + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); } export function getBundledChannelSetupSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - if (lazySetupSecretsById.has(id)) { - return lazySetupSecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; - if (!entry) { - return undefined; - } - const secrets = entry.loadSetupSecrets?.() ?? getBundledChannelSetupPlugin(id)?.secrets; - lazySetupSecretsById.set(id, secrets ?? null); - return secrets; + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelSetupSecretsForRoot(id, rootScope, cacheContext); } export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { @@ -366,7 +478,9 @@ export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { - const setter = getLazyGeneratedBundledChannelEntry(id)?.entry.setChannelRuntime; + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry + .setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); } 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..c961008421c --- /dev/null +++ b/src/plugin-sdk/runtime-store.test.ts @@ -0,0 +1,118 @@ +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 isolated per store", () => { + 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(firstStore.getRuntime()).toEqual({ value: "legacy" }); + expect(secondStore.tryGetRuntime()).toBeNull(); + }); + + 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("rejects empty plugin ids", () => { + expect(() => + createPluginRuntimeStore({ + pluginId: " ", + errorMessage: "runtime not initialized", + }), + ).toThrow("pluginId must not be empty"); + }); + + test("treats falsy runtime values as initialized", () => { + const store = createPluginRuntimeStore({ + key: "custom-falsy-runtime-key", + errorMessage: "runtime not initialized", + }); + + store.clearRuntime(); + store.setRuntime(0); + + expect(store.getRuntime()).toBe(0); + }); + + 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..98a84f686d6 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -1,29 +1,97 @@ 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 { + const normalizedPluginId = pluginId.trim(); + if (!normalizedPluginId) { + throw new Error("createPluginRuntimeStore: pluginId must not be empty"); + } + return `plugin-runtime:${normalizedPluginId}`; +} + +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 slot = + typeof options === "string" + ? { runtime: null } + : (() => { + const registry = getPluginRuntimeStoreRegistry(); + let existingSlot = registry.get(resolved.key); + if (!existingSlot) { + existingSlot = { runtime: null }; + registry.set(resolved.key, existingSlot); + } + return existingSlot; + })(); 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 === null) { + throw new Error(resolved.errorMessage); } - return runtime; + return slot.runtime as T; }, }; } diff --git a/src/plugins/bundled-channel-runtime.ts b/src/plugins/bundled-channel-runtime.ts index 5e751dbf20f..57f00e90909 100644 --- a/src/plugins/bundled-channel-runtime.ts +++ b/src/plugins/bundled-channel-runtime.ts @@ -9,6 +9,7 @@ export type BundledChannelPluginMetadata = BundledPluginMetadata; export function listBundledChannelPluginMetadata(params?: { rootDir?: string; + scanDir?: string; includeChannelConfigs?: boolean; includeSyntheticChannelConfigs?: boolean; }): readonly BundledChannelPluginMetadata[] { @@ -19,12 +20,14 @@ export function resolveBundledChannelGeneratedPath( rootDir: string, entry: BundledPluginMetadata["source"] | BundledPluginMetadata["setupSource"], pluginDirName?: string, + scanDir?: string, ): string | null { - return resolveBundledPluginGeneratedPath(rootDir, entry, pluginDirName); + return resolveBundledPluginGeneratedPath(rootDir, entry, pluginDirName, scanDir); } export function resolveBundledChannelWorkspacePath(params: { rootDir: string; + scanDir?: string; pluginId: string; }): string | null { return resolveBundledPluginWorkspaceSourcePath(params); diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 9bbbb92693a..ee9d4e8fa31 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -274,6 +274,45 @@ describe("bundled plugin metadata", () => { ); }); + it("scans direct plugin-tree overrides and resolves generated paths from that scan dir", () => { + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-direct-tree-"); + const pluginsDir = path.join(tempRoot, "bundled-plugins"); + const pluginRoot = path.join(pluginsDir, "alpha"); + + writeJson(path.join(pluginRoot, "package.json"), { + name: "@openclaw/alpha", + version: "0.0.1", + openclaw: { + extensions: ["./index.ts"], + }, + }); + writeJson(path.join(pluginRoot, "openclaw.plugin.json"), { + id: "alpha", + channels: ["alpha"], + configSchema: { type: "object" }, + }); + fs.writeFileSync(path.join(pluginRoot, "index.ts"), "export const source = true;\n", "utf8"); + + clearBundledPluginMetadataCache(); + expect( + listBundledPluginMetadata({ + rootDir: tempRoot, + scanDir: pluginsDir, + }).map((entry) => entry.manifest.id), + ).toEqual(["alpha"]); + expect( + resolveBundledPluginGeneratedPath( + tempRoot, + { + source: "./index.ts", + built: "index.js", + }, + "alpha", + pluginsDir, + ), + ).toBe(path.join(pluginRoot, "index.ts")); + }); + it("resolves bundled repo entry paths from dist before workspace source", () => { const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-repo-entry-"); const pluginRoot = path.join(tempRoot, "extensions", "alpha"); diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index 63283cd48b0..be3b06c567c 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -67,26 +67,44 @@ function readPackageManifest(pluginDir: string): PackageManifest | undefined { } } -function collectBundledPluginMetadataForPackageRoot( +function resolveBundledPluginMetadataScanDir( packageRoot: string, - includeChannelConfigs: boolean, - includeSyntheticChannelConfigs: boolean, -): readonly BundledPluginMetadata[] { - const scanDir = resolveBundledPluginScanDir({ + scanDir?: string, +): string | undefined { + if (scanDir) { + return path.resolve(scanDir); + } + return resolveBundledPluginScanDir({ packageRoot, runningFromBuiltArtifact: RUNNING_FROM_BUILT_ARTIFACT, }); - if (!scanDir || !fs.existsSync(scanDir)) { +} + +function resolveBundledPluginLookupParams(params: { rootDir: string; scanDir?: string }): { + rootDir: string; + scanDir?: string; +} { + return params.scanDir ? params : { rootDir: params.rootDir }; +} + +function collectBundledPluginMetadata( + packageRoot: string, + includeChannelConfigs: boolean, + includeSyntheticChannelConfigs: boolean, + scanDir?: string, +): readonly BundledPluginMetadata[] { + const resolvedScanDir = resolveBundledPluginMetadataScanDir(packageRoot, scanDir); + if (!resolvedScanDir || !fs.existsSync(resolvedScanDir)) { return []; } const entries: BundledPluginMetadata[] = []; for (const dirName of fs - .readdirSync(scanDir, { withFileTypes: true }) + .readdirSync(resolvedScanDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .toSorted((left, right) => left.localeCompare(right))) { - const pluginDir = path.join(scanDir, dirName); + const pluginDir = path.join(resolvedScanDir, dirName); const manifestResult = loadPluginManifest(pluginDir, false); if (!manifestResult.ok) { continue; @@ -165,15 +183,18 @@ function collectBundledPluginMetadataForPackageRoot( export function listBundledPluginMetadata(params?: { rootDir?: string; + scanDir?: string; includeChannelConfigs?: boolean; includeSyntheticChannelConfigs?: boolean; }): readonly BundledPluginMetadata[] { const rootDir = path.resolve(params?.rootDir ?? OPENCLAW_PACKAGE_ROOT); + const scanDir = params?.scanDir ? path.resolve(params.scanDir) : undefined; const includeChannelConfigs = params?.includeChannelConfigs ?? !RUNNING_FROM_BUILT_ARTIFACT; const includeSyntheticChannelConfigs = params?.includeSyntheticChannelConfigs ?? includeChannelConfigs; const cacheKey = JSON.stringify({ rootDir, + scanDir, includeChannelConfigs, includeSyntheticChannelConfigs, }); @@ -182,10 +203,11 @@ export function listBundledPluginMetadata(params?: { return cached; } const entries = Object.freeze( - collectBundledPluginMetadataForPackageRoot( + collectBundledPluginMetadata( rootDir, includeChannelConfigs, includeSyntheticChannelConfigs, + scanDir, ), ); bundledPluginMetadataCache.set(cacheKey, entries); @@ -194,26 +216,50 @@ export function listBundledPluginMetadata(params?: { export function findBundledPluginMetadataById( pluginId: string, - params?: { rootDir?: string }, + params?: { rootDir?: string; scanDir?: string }, ): BundledPluginMetadata | undefined { return listBundledPluginMetadata(params).find((entry) => entry.manifest.id === pluginId); } export function resolveBundledPluginWorkspaceSourcePath(params: { rootDir: string; + scanDir?: string; pluginId: string; }): string | null { - const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir }); + const metadata = findBundledPluginMetadataById( + params.pluginId, + resolveBundledPluginLookupParams({ + rootDir: params.rootDir, + scanDir: params.scanDir, + }), + ); if (!metadata) { return null; } + if (params.scanDir) { + return path.resolve(params.scanDir, metadata.dirName); + } return path.resolve(params.rootDir, "extensions", metadata.dirName); } +function listBundledPluginEntryBaseDirs(params: { + rootDir: string; + pluginDirName?: string; + scanDir?: string; +}): string[] { + const baseDirs = [ + path.resolve(params.rootDir, "dist", "extensions", params.pluginDirName ?? ""), + path.resolve(params.rootDir, "extensions", params.pluginDirName ?? ""), + ...(params.scanDir ? [path.resolve(params.scanDir, params.pluginDirName ?? "")] : []), + ]; + return baseDirs.filter((entry, index, all) => all.indexOf(entry) === index); +} + export function resolveBundledPluginGeneratedPath( rootDir: string, entry: BundledPluginPathPair | undefined, pluginDirName?: string, + scanDir?: string, ): string | null { if (!entry) { return null; @@ -221,10 +267,11 @@ export function resolveBundledPluginGeneratedPath( const entryOrder = [entry.built, entry.source].filter( (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0, ); - const baseDirs = [ - path.resolve(rootDir, "dist", "extensions", pluginDirName ?? ""), - path.resolve(rootDir, "extensions", pluginDirName ?? ""), - ]; + const baseDirs = listBundledPluginEntryBaseDirs({ + rootDir, + pluginDirName, + ...(scanDir ? { scanDir } : {}), + }); for (const baseDir of baseDirs) { for (const entryPath of entryOrder) { const candidate = path.resolve(baseDir, normalizeRelativePluginEntryPath(entryPath)); @@ -244,8 +291,15 @@ export function resolveBundledPluginRepoEntryPath(params: { rootDir: string; pluginId: string; preferBuilt?: boolean; + scanDir?: string; }): string | null { - const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir }); + const metadata = findBundledPluginMetadataById( + params.pluginId, + resolveBundledPluginLookupParams({ + rootDir: params.rootDir, + scanDir: params.scanDir, + }), + ); if (!metadata) { return null; } @@ -253,10 +307,11 @@ export function resolveBundledPluginRepoEntryPath(params: { const entryOrder = params.preferBuilt ? [metadata.source.built, metadata.source.source] : [metadata.source.source, metadata.source.built]; - const baseDirs = [ - path.resolve(params.rootDir, "dist", "extensions", metadata.dirName), - path.resolve(params.rootDir, "extensions", metadata.dirName), - ]; + const baseDirs = listBundledPluginEntryBaseDirs({ + rootDir: params.rootDir, + pluginDirName: metadata.dirName, + ...(params.scanDir ? { scanDir: params.scanDir } : {}), + }); for (const baseDir of baseDirs) { for (const entryPath of entryOrder) { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d6e761934d5..2ff7080638a 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -522,8 +522,15 @@ function createSetupEntryChannelPluginFixture(params: { setupBlurb: string; configured: boolean; startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; + useBundledFullEntryContract?: boolean; + bundledFullEntryId?: string; useBundledSetupEntryContract?: boolean; + bundledSetupEntryId?: string; splitBundledSetupSecrets?: boolean; + bundledSetupRuntimeMarker?: string; + bundledSetupRuntimeError?: string; + bundledFullRuntimeMarker?: string; + requireBundledFullRuntimeBeforeLoad?: boolean; }) { useNoBundledPlugins(); const pluginDir = makeTempDir(); @@ -571,7 +578,48 @@ 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.bundledFullEntryId ?? params.id)}, + name: ${JSON.stringify(params.label)}, + description: ${JSON.stringify(params.fullBlurb)}, + loadChannelPlugin: () => { + ${ + params.requireBundledFullRuntimeBeforeLoad && params.bundledFullRuntimeMarker + ? `if (!require("node:fs").existsSync(${JSON.stringify(params.bundledFullRuntimeMarker)})) { + throw new Error("bundled runtime not initialized"); + }` + : "" + } + return { + id: ${JSON.stringify(params.bundledFullEntryId ?? params.id)}, + meta: { + id: ${JSON.stringify(params.bundledFullEntryId ?? params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.bundledFullEntryId ?? 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) { @@ -604,12 +652,12 @@ module.exports = { module.exports = { kind: "bundled-channel-setup-entry", loadSetupPlugin: () => ({ - id: ${JSON.stringify(params.id)}, + id: ${JSON.stringify(params.bundledSetupEntryId ?? params.id)}, meta: { - id: ${JSON.stringify(params.id)}, + id: ${JSON.stringify(params.bundledSetupEntryId ?? params.id)}, label: ${JSON.stringify(params.label)}, selectionLabel: ${JSON.stringify(params.label)}, - docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + docsPath: ${JSON.stringify(`/channels/${params.bundledSetupEntryId ?? params.id}`)}, blurb: ${JSON.stringify(params.setupBlurb)}, }, capabilities: { chatTypes: ["direct"] }, @@ -631,6 +679,17 @@ module.exports = { }),` : "" } + ${ + params.bundledSetupRuntimeError + ? `setChannelRuntime: () => { + throw new Error(${JSON.stringify(params.bundledSetupRuntimeError)}); + },` + : 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 = { @@ -3223,6 +3282,36 @@ module.exports = { expectSetupLoaded: true, expectedChannels: 0, }, + { + name: "keeps bundled setupEntry setup-only loads on the setup-safe path", + fixture: { + id: "setup-only-bundled-contract-test", + label: "Setup Only Bundled Contract Test", + packageName: "@openclaw/setup-only-bundled-contract-test", + fullBlurb: "full entry should not run in setup-only mode", + setupBlurb: "setup-only bundled contract", + configured: false, + useBundledSetupEntryContract: true, + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-only-bundled-contract-test"], + entries: { + "setup-only-bundled-contract-test": { enabled: false }, + }, + }, + }, + includeSetupOnlyChannelPlugins: true, + onlyPluginIds: ["setup-only-bundled-contract-test"], + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 0, + }, { name: "uses package setupEntry for enabled but unconfigured channel loads", fixture: { @@ -3268,7 +3357,7 @@ module.exports = { }, }, }), - expectFullLoaded: false, + expectFullLoaded: true, expectSetupLoaded: true, expectedChannels: 1, }, @@ -3294,11 +3383,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 +3483,8 @@ module.exports = { expectSetupLoaded, expectedChannels, expectedSetupSecretId, + expectSetupRuntimeLoaded, + expectBundledFullRuntimeLoaded, }) => { const built = createSetupEntryChannelPluginFixture(fixture); const registry = load({ pluginDir: built.pluginDir }); @@ -3347,6 +3493,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([ @@ -3366,6 +3522,146 @@ module.exports = { }, ); + it("applies the bundled runtime setter before loading the merged setup-runtime plugin", () => { + const runtimeMarker = path.join(makeTempDir(), "setup-runtime-before-load.txt"); + const built = createSetupEntryChannelPluginFixture({ + id: "setup-runtime-order-test", + label: "Setup Runtime Order Test", + packageName: "@openclaw/setup-runtime-order-test", + fullBlurb: "full runtime plugin", + setupBlurb: "setup runtime override", + configured: false, + useBundledFullEntryContract: true, + useBundledSetupEntryContract: true, + bundledFullRuntimeMarker: runtimeMarker, + requireBundledFullRuntimeBeforeLoad: true, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [built.pluginDir] }, + allow: ["setup-runtime-order-test"], + }, + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "setup-runtime-order-test")?.status).toBe( + "loaded", + ); + expect(fs.existsSync(runtimeMarker)).toBe(true); + }); + + it("records setup runtime setter failures without aborting the full load pass", () => { + const built = createSetupEntryChannelPluginFixture({ + id: "setup-runtime-error-test", + label: "Setup Runtime Error Test", + packageName: "@openclaw/setup-runtime-error-test", + fullBlurb: "full runtime plugin", + setupBlurb: "setup runtime override", + configured: false, + useBundledSetupEntryContract: true, + bundledSetupRuntimeError: "broken setup runtime setter", + }); + const helperPlugin = writePlugin({ + id: "setup-runtime-helper-test", + filename: "setup-runtime-helper-test.cjs", + body: `module.exports = { id: "setup-runtime-helper-test", register() {} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [built.pluginDir, helperPlugin.file] }, + allow: ["setup-runtime-error-test", "setup-runtime-helper-test"], + }, + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "setup-runtime-error-test")?.status).toBe( + "error", + ); + expect( + registry.plugins.find((entry) => entry.id === "setup-runtime-error-test")?.error, + ).toContain("broken setup runtime setter"); + expect(registry.plugins.find((entry) => entry.id === "setup-runtime-helper-test")?.status).toBe( + "loaded", + ); + }); + + it("rejects mismatched bundled runtime entry ids before applying setup-runtime setters", () => { + const runtimeMarker = path.join(makeTempDir(), "setup-runtime-mismatch.txt"); + const built = createSetupEntryChannelPluginFixture({ + id: "setup-runtime-mismatch-test", + bundledFullEntryId: "wrong-runtime-id", + label: "Setup Runtime Mismatch Test", + packageName: "@openclaw/setup-runtime-mismatch-test", + fullBlurb: "full runtime plugin", + setupBlurb: "setup runtime override", + configured: false, + useBundledFullEntryContract: true, + useBundledSetupEntryContract: true, + bundledFullRuntimeMarker: runtimeMarker, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [built.pluginDir] }, + allow: ["setup-runtime-mismatch-test"], + }, + }, + }); + + expect( + registry.plugins.find((entry) => entry.id === "setup-runtime-mismatch-test")?.status, + ).toBe("error"); + expect( + registry.plugins.find((entry) => entry.id === "setup-runtime-mismatch-test")?.error, + ).toContain('runtime entry uses "wrong-runtime-id"'); + expect(registry.channels).toHaveLength(0); + expect(fs.existsSync(runtimeMarker)).toBe(false); + }); + + it("rejects mismatched bundled setup export ids before loading setup-runtime entry code", () => { + const runtimeMarker = path.join(makeTempDir(), "setup-runtime-mismatch.txt"); + const built = createSetupEntryChannelPluginFixture({ + id: "setup-export-mismatch-test", + bundledSetupEntryId: "wrong-setup-id", + label: "Setup Export Mismatch Test", + packageName: "@openclaw/setup-export-mismatch-test", + fullBlurb: "full runtime plugin", + setupBlurb: "setup runtime override", + configured: false, + useBundledFullEntryContract: true, + useBundledSetupEntryContract: true, + bundledFullRuntimeMarker: runtimeMarker, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [built.pluginDir] }, + allow: ["setup-export-mismatch-test"], + }, + }, + }); + + expect( + registry.plugins.find((entry) => entry.id === "setup-export-mismatch-test")?.status, + ).toBe("error"); + expect( + registry.plugins.find((entry) => entry.id === "setup-export-mismatch-test")?.error, + ).toContain('setup export uses "wrong-setup-id"'); + expect(registry.channels).toHaveLength(0); + expect(fs.existsSync(built.fullMarker)).toBe(false); + expect(fs.existsSync(runtimeMarker)).toBe(false); + }); + it("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => { useNoBundledPlugins(); const pluginDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c821f7ede85..85563ed8798 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,102 @@ 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): { + id?: string; + loadChannelPlugin?: () => ChannelPlugin; + loadChannelSecrets?: () => ChannelPlugin["secrets"] | undefined; + setChannelRuntime?: (runtime: PluginRuntime) => void; +} { + const resolved = unwrapDefaultModuleExport(moduleExport); + if (!resolved || typeof resolved !== "object") { + return {}; + } + const entryRecord = resolved as { + kind?: unknown; + id?: unknown; + loadChannelPlugin?: unknown; + loadChannelSecrets?: unknown; + setChannelRuntime?: unknown; + }; + if ( + entryRecord.kind !== "bundled-channel-entry" || + typeof entryRecord.id !== "string" || + typeof entryRecord.loadChannelPlugin !== "function" + ) { + return {}; + } + return { + id: entryRecord.id, + loadChannelPlugin: entryRecord.loadChannelPlugin as () => ChannelPlugin, + ...(typeof entryRecord.loadChannelSecrets === "function" + ? { + loadChannelSecrets: entryRecord.loadChannelSecrets as () => + | ChannelPlugin["secrets"] + | undefined, + } + : {}), + ...(typeof entryRecord.setChannelRuntime === "function" + ? { + setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void, + } + : {}), + }; +} + +function loadBundledRuntimeChannelPlugin(params: { + registration: ReturnType; +}): { + plugin?: ChannelPlugin; + loadError?: unknown; +} { + if (typeof params.registration.loadChannelPlugin !== "function") { + return {}; + } + try { + const loadedPlugin = params.registration.loadChannelPlugin(); + const loadedSecrets = params.registration.loadChannelSecrets?.(); + if (!loadedPlugin || typeof loadedPlugin !== "object") { + return {}; + } + const mergedSecrets = mergeChannelPluginSection(loadedPlugin.secrets, loadedSecrets); + return { + plugin: { + ...loadedPlugin, + ...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}), + }, + }; + } catch (err) { + return { loadError: err }; + } } function resolveSetupChannelRegistration(moduleExport: unknown): { plugin?: ChannelPlugin; + setChannelRuntime?: (runtime: PluginRuntime) => void; + usesBundledSetupContract?: boolean; loadError?: unknown; } { const resolved = unwrapDefaultModuleExport(moduleExport); @@ -670,6 +766,7 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { kind?: unknown; loadSetupPlugin?: unknown; loadSetupSecrets?: unknown; + setChannelRuntime?: unknown; }; if ( setupEntryRecord.kind === "bundled-channel-setup-entry" && @@ -682,7 +779,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 +788,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) { @@ -1709,7 +1814,147 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); - api.registerChannel(setupRegistration.plugin); + let mergedSetupRegistration = setupRegistration; + let runtimeSetterApplied = false; + if ( + registrationMode === "setup-runtime" && + 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.id && runtimeRegistration.id !== record.id) { + pushPluginLoadError( + `plugin id mismatch (config uses "${record.id}", runtime entry uses "${runtimeRegistration.id}")`, + ); + continue; + } + if (runtimeRegistration.setChannelRuntime) { + try { + runtimeRegistration.setChannelRuntime(api.runtime); + runtimeSetterApplied = true; + } catch (err) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: err, + logPrefix: `[plugins] ${record.id} failed to apply setup-runtime channel runtime from ${record.source}: `, + diagnosticMessagePrefix: "failed to apply setup-runtime channel runtime: ", + }); + continue; + } + } + const runtimePluginRegistration = loadBundledRuntimeChannelPlugin({ + registration: runtimeRegistration, + }); + if (runtimePluginRegistration.loadError) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: runtimePluginRegistration.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 (runtimePluginRegistration.plugin) { + if ( + runtimePluginRegistration.plugin.id && + runtimePluginRegistration.plugin.id !== record.id + ) { + pushPluginLoadError( + `plugin id mismatch (config uses "${record.id}", runtime export uses "${runtimePluginRegistration.plugin.id}")`, + ); + continue; + } + mergedSetupRegistration = { + ...setupRegistration, + plugin: mergeSetupRuntimeChannelPlugin( + runtimePluginRegistration.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 "${mergedSetupPlugin.id}")`, + ); + continue; + } + if (!runtimeSetterApplied) { + try { + mergedSetupRegistration.setChannelRuntime?.(api.runtime); + } catch (err) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: err, + logPrefix: `[plugins] ${record.id} failed to apply setup channel runtime from ${record.source}: `, + diagnosticMessagePrefix: "failed to apply setup channel runtime: ", + }); + continue; + } + } + api.registerChannel(mergedSetupPlugin); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue;