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:
Gustavo Madeira Santana
2026-04-15 12:35:18 -04:00
committed by GitHub
parent ee6b7daca3
commit 78ac118427
43 changed files with 1831 additions and 209 deletions

View File

@@ -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

View File

@@ -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">

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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();
}

View File

@@ -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 };

View File

@@ -10,4 +10,8 @@ export default defineBundledChannelSetupEntry({
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setMatrixRuntime",
},
});

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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();
}

View File

@@ -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);
}

View 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");
});
});

View 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,
};
}

View File

@@ -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[] = [];

View File

@@ -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}`);
}

View File

@@ -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 } : {}),
};
}

View 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" });
});
});

View File

@@ -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;
},
};
}

View File

@@ -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);

View File

@@ -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");

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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;