mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
fix(plugins): stabilize bundled setup runtimes (#67200)
Merged via squash.
Prepared head SHA: e8d6738fd0
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
ee6b7daca3
commit
78ac118427
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Handle inbound messages">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<PluginRuntime>("my-plugin runtime not initialized");
|
||||
const store = createPluginRuntimeStore<PluginRuntime>({
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PluginRuntime>("test runtime not set");
|
||||
const store = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "test-plugin",
|
||||
errorMessage: "test runtime not set",
|
||||
});
|
||||
|
||||
// In test setup
|
||||
const mockRuntime = {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "bluebubbles",
|
||||
errorMessage: "BlueBubbles runtime not initialized",
|
||||
});
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
|
||||
|
||||
|
||||
@@ -16,5 +16,8 @@ const {
|
||||
setRuntime: setDiscordRuntime,
|
||||
tryGetRuntime: getOptionalDiscordRuntime,
|
||||
getRuntime: getDiscordRuntime,
|
||||
} = createPluginRuntimeStore<DiscordRuntime>("Discord runtime not initialized");
|
||||
} = createPluginRuntimeStore<DiscordRuntime>({
|
||||
pluginId: "discord",
|
||||
errorMessage: "Discord runtime not initialized",
|
||||
});
|
||||
export { getDiscordRuntime, getOptionalDiscordRuntime, setDiscordRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Feishu runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "feishu",
|
||||
errorMessage: "Feishu runtime not initialized",
|
||||
});
|
||||
export { getFeishuRuntime, setFeishuRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Google Chat runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "googlechat",
|
||||
errorMessage: "Google Chat runtime not initialized",
|
||||
});
|
||||
export { getGoogleChatRuntime, setGoogleChatRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("iMessage runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "imessage",
|
||||
errorMessage: "iMessage runtime not initialized",
|
||||
});
|
||||
export { getIMessageRuntime, setIMessageRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("IRC runtime not initialized");
|
||||
const {
|
||||
setRuntime: setIrcRuntime,
|
||||
clearRuntime: clearStoredIrcRuntime,
|
||||
getRuntime: getIrcRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "irc",
|
||||
errorMessage: "IRC runtime not initialized",
|
||||
});
|
||||
export { getIrcRuntime, setIrcRuntime };
|
||||
export function clearIrcRuntime() {
|
||||
setIrcRuntime(undefined as unknown as PluginRuntime);
|
||||
clearStoredIrcRuntime();
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@ const {
|
||||
setRuntime: setLineRuntime,
|
||||
clearRuntime: clearLineRuntime,
|
||||
getRuntime: getLineRuntime,
|
||||
} = createPluginRuntimeStore<LineRuntime>("LINE runtime not initialized - plugin not registered");
|
||||
} = createPluginRuntimeStore<LineRuntime>({
|
||||
pluginId: "line",
|
||||
errorMessage: "LINE runtime not initialized - plugin not registered",
|
||||
});
|
||||
export { clearLineRuntime, getLineRuntime, setLineRuntime };
|
||||
|
||||
@@ -10,4 +10,8 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setMatrixRuntime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<PluginRuntime>("Matrix runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "matrix",
|
||||
errorMessage: "Matrix runtime not initialized",
|
||||
});
|
||||
|
||||
export { getMatrixRuntime, setMatrixRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Mattermost runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "mattermost",
|
||||
errorMessage: "Mattermost runtime not initialized",
|
||||
});
|
||||
export { getMattermostRuntime, setMattermostRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("MSTeams runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "msteams",
|
||||
errorMessage: "MSTeams runtime not initialized",
|
||||
});
|
||||
export { getMSTeamsRuntime, setMSTeamsRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Nextcloud Talk runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "nextcloud-talk",
|
||||
errorMessage: "Nextcloud Talk runtime not initialized",
|
||||
});
|
||||
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Nostr runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "nostr",
|
||||
errorMessage: "Nostr runtime not initialized",
|
||||
});
|
||||
export { getNostrRuntime, setNostrRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("QQBot runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "qqbot",
|
||||
errorMessage: "QQBot runtime not initialized",
|
||||
});
|
||||
export { getQQBotRuntime, setQQBotRuntime };
|
||||
|
||||
@@ -5,5 +5,8 @@ const {
|
||||
setRuntime: setSignalRuntime,
|
||||
clearRuntime: clearSignalRuntime,
|
||||
getRuntime: getSignalRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "signal",
|
||||
errorMessage: "Signal runtime not initialized",
|
||||
});
|
||||
export { clearSignalRuntime, getSignalRuntime, setSignalRuntime };
|
||||
|
||||
@@ -16,5 +16,8 @@ const {
|
||||
clearRuntime: clearSlackRuntime,
|
||||
tryGetRuntime: getOptionalSlackRuntime,
|
||||
getRuntime: getSlackRuntime,
|
||||
} = createPluginRuntimeStore<SlackRuntime>("Slack runtime not initialized");
|
||||
} = createPluginRuntimeStore<SlackRuntime>({
|
||||
pluginId: "slack",
|
||||
errorMessage: "Slack runtime not initialized",
|
||||
});
|
||||
export { clearSlackRuntime, getOptionalSlackRuntime, getSlackRuntime, setSlackRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>(
|
||||
"Synology Chat runtime not initialized - plugin not registered",
|
||||
);
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "synology-chat",
|
||||
errorMessage: "Synology Chat runtime not initialized - plugin not registered",
|
||||
});
|
||||
export { getSynologyRuntime, setSynologyRuntime };
|
||||
|
||||
@@ -6,5 +6,8 @@ const {
|
||||
setRuntime: setTelegramRuntime,
|
||||
clearRuntime: clearTelegramRuntime,
|
||||
getRuntime: getTelegramRuntime,
|
||||
} = createPluginRuntimeStore<TelegramRuntime>("Telegram runtime not initialized");
|
||||
} = createPluginRuntimeStore<TelegramRuntime>({
|
||||
pluginId: "telegram",
|
||||
errorMessage: "Telegram runtime not initialized",
|
||||
});
|
||||
export { clearTelegramRuntime, getTelegramRuntime, setTelegramRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Tlon runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "tlon",
|
||||
errorMessage: "Tlon runtime not initialized",
|
||||
});
|
||||
export { getTlonRuntime, setTlonRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Twitch runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "twitch",
|
||||
errorMessage: "Twitch runtime not initialized",
|
||||
});
|
||||
export { getTwitchRuntime, setTwitchRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("WhatsApp runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "whatsapp",
|
||||
errorMessage: "WhatsApp runtime not initialized",
|
||||
});
|
||||
export { getWhatsAppRuntime, setWhatsAppRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Zalo runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalo",
|
||||
errorMessage: "Zalo runtime not initialized",
|
||||
});
|
||||
export { getZaloRuntime, setZaloRuntime };
|
||||
|
||||
@@ -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<PluginRuntime>("Zalouser runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalouser",
|
||||
errorMessage: "Zalouser runtime not initialized",
|
||||
});
|
||||
export { getZalouserRuntime, setZalouserRuntime };
|
||||
|
||||
@@ -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<string>;
|
||||
};
|
||||
|
||||
let cachedBootstrapPlugins: CachedBootstrapPlugins | null = null;
|
||||
const cachedBootstrapPluginsByRoot = new Map<string, CachedBootstrapPlugins>();
|
||||
|
||||
function resolveBootstrapChannelId(id: ChannelId): string {
|
||||
return normalizeOptionalString(id) ?? "";
|
||||
}
|
||||
|
||||
function mergePluginSection<T>(
|
||||
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<ChannelPlugin> {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
147
src/channels/plugins/bundled-root-caches.test.ts
Normal file
147
src/channels/plugins/bundled-root-caches.test.ts
Normal file
@@ -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<typeof import("./bundled-ids.js")>(
|
||||
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<typeof import("./bootstrap-registry.js")>(
|
||||
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");
|
||||
});
|
||||
});
|
||||
50
src/channels/plugins/bundled-root.ts
Normal file
50
src/channels/plugins/bundled-root.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import("./bundled.js")>(
|
||||
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<typeof import("./bundled.js")>(
|
||||
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<typeof import("./bundled.js")>(
|
||||
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[] = [];
|
||||
|
||||
|
||||
@@ -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<ChannelId>;
|
||||
setupPluginLoadInProgressIds: Set<ChannelId>;
|
||||
entryLoadInProgressIds: Set<ChannelId>;
|
||||
lazyEntriesById: Map<ChannelId, GeneratedBundledChannelEntry | null>;
|
||||
lazyPluginsById: Map<ChannelId, ChannelPlugin>;
|
||||
lazySetupPluginsById: Map<ChannelId, ChannelPlugin>;
|
||||
lazySecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
|
||||
lazySetupSecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
|
||||
};
|
||||
|
||||
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<string, readonly BundledChannelPluginMetadata[]>();
|
||||
const bundledChannelCacheContexts = new Map<string, BundledChannelCacheContext>();
|
||||
|
||||
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<ChannelId>();
|
||||
const setupPluginLoadInProgressIds = new Set<ChannelId>();
|
||||
const entryLoadInProgressIds = new Set<ChannelId>();
|
||||
const lazyEntriesById = new Map<ChannelId, GeneratedBundledChannelEntry | null>();
|
||||
const lazyPluginsById = new Map<ChannelId, ChannelPlugin>();
|
||||
const lazySetupPluginsById = new Map<ChannelId, ChannelPlugin>();
|
||||
const lazySecretsById = new Map<ChannelId, ChannelPlugin["secrets"] | null>();
|
||||
const lazySetupSecretsById = new Map<ChannelId, ChannelPlugin["secrets"] | null>();
|
||||
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<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
): 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}`);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type DefineBundledChannelSetupEntryOptions = {
|
||||
importMetaUrl: string;
|
||||
plugin: BundledEntryModuleRef;
|
||||
secrets?: BundledEntryModuleRef;
|
||||
runtime?: BundledEntryModuleRef;
|
||||
features?: BundledChannelSetupEntryFeatures;
|
||||
};
|
||||
|
||||
@@ -68,6 +69,7 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
|
||||
kind: "bundled-channel-setup-entry";
|
||||
loadSetupPlugin: () => TPlugin;
|
||||
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
|
||||
setChannelRuntime?: (runtime: PluginRuntime) => void;
|
||||
features?: BundledChannelSetupEntryFeatures;
|
||||
};
|
||||
|
||||
@@ -380,8 +382,21 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
|
||||
importMetaUrl,
|
||||
plugin,
|
||||
secrets,
|
||||
runtime,
|
||||
features,
|
||||
}: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract<TPlugin> {
|
||||
// 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<TPlugin>(importMetaUrl, plugin),
|
||||
@@ -394,6 +409,7 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(setChannelRuntime ? { setChannelRuntime } : {}),
|
||||
...(features ? { features } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
118
src/plugin-sdk/runtime-store.test.ts
Normal file
118
src/plugin-sdk/runtime-store.test.ts
Normal file
@@ -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<number>({
|
||||
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<typeof import("./runtime-store.js")>(
|
||||
import.meta.url,
|
||||
"./runtime-store.js?scope=runtime-store-a",
|
||||
);
|
||||
const secondModule = await importFreshModule<typeof import("./runtime-store.js")>(
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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<string, { runtime: unknown }>;
|
||||
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<T>(errorMessage: string): {
|
||||
setRuntime: (next: T) => void;
|
||||
clearRuntime: () => void;
|
||||
tryGetRuntime: () => T | null;
|
||||
getRuntime: () => T;
|
||||
};
|
||||
export function createPluginRuntimeStore<T>(options: PluginRuntimeStoreOptions): {
|
||||
setRuntime: (next: T) => void;
|
||||
clearRuntime: () => void;
|
||||
tryGetRuntime: () => T | null;
|
||||
getRuntime: () => T;
|
||||
};
|
||||
export function createPluginRuntimeStore<T>(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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -638,15 +638,20 @@ function resolvePluginModuleExport(moduleExport: unknown): {
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeSetupPluginSection<T>(
|
||||
function mergeChannelPluginSection<T>(
|
||||
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<string, unknown>),
|
||||
};
|
||||
for (const [key, value] of Object.entries(setupValue as Record<string, unknown>)) {
|
||||
for (const [key, value] of Object.entries(overrideValue as Record<string, unknown>)) {
|
||||
if (value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
@@ -655,11 +660,102 @@ function mergeSetupPluginSection<T>(
|
||||
...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<typeof resolveBundledRuntimeChannelRegistration>;
|
||||
}): {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user