fix(plugins): stabilize bundled setup runtimes

This commit is contained in:
Gustavo Madeira Santana
2026-04-15 09:26:25 -04:00
parent ee6b7daca3
commit 175473c927
35 changed files with 727 additions and 58 deletions

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

@@ -77,6 +77,94 @@ describe("bundled channel entry shape guards", () => {
).not.toThrow();
});
it("uses the active bundled plugin root override for channel entry loading", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-override-"));
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const pluginDir = path.join(tempRoot, "dist", "extensions", "alpha");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "index.js"),
[
"globalThis.__bundledOverrideRuntime = undefined;",
"const plugin = { id: 'alpha', meta: {}, capabilities: {}, config: {} };",
"export default {",
" kind: 'bundled-channel-entry',",
" id: 'alpha',",
" name: 'Alpha',",
" description: 'Alpha',",
" register() {},",
" loadChannelPlugin() { return plugin; },",
" setChannelRuntime(runtime) { globalThis.__bundledOverrideRuntime = runtime.marker; },",
"};",
"",
].join("\n"),
"utf8",
);
let metadataRootDir: string | undefined;
let generatedRootDir: string | undefined;
vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({
listBundledChannelPluginMetadata: (params?: { rootDir?: string }) => {
metadataRootDir = params?.rootDir;
return [
{
dirName: "alpha",
manifest: {
id: "alpha",
channels: ["alpha"],
},
source: {
source: "./index.js",
built: "./index.js",
},
},
];
},
resolveBundledChannelGeneratedPath: (
rootDir: string,
entry: { built?: string; source?: string },
pluginDirName?: string,
) => {
generatedRootDir = rootDir;
return path.join(
rootDir,
"dist",
"extensions",
pluginDirName ?? "alpha",
(entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""),
);
},
}));
try {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(tempRoot, "dist", "extensions");
const bundled = await importFreshModule<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("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => {
const offenders: string[] = [];

View File

@@ -8,6 +8,7 @@ import {
resolveBundledChannelGeneratedPath,
type BundledChannelPluginMetadata,
} from "../../plugins/bundled-channel-runtime.js";
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
import { unwrapDefaultModuleExport } from "../../plugins/module-export.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js";
@@ -52,6 +53,27 @@ const OPENCLAW_PACKAGE_ROOT =
? path.resolve(fileURLToPath(new URL("../../..", import.meta.url)))
: process.cwd());
function derivePackageRootFromBundledPluginsDir(pluginsDir: string): string {
const resolvedDir = path.resolve(pluginsDir);
if (path.basename(resolvedDir) !== "extensions") {
return resolvedDir;
}
const parentDir = path.dirname(resolvedDir);
const parentBase = path.basename(parentDir);
if (parentBase === "dist" || parentBase === "dist-runtime") {
return path.dirname(parentDir);
}
return parentDir;
}
function resolveBundledChannelPackageRoot(): string {
const bundledPluginsDir = resolveBundledPluginsDir(process.env);
if (bundledPluginsDir) {
return derivePackageRootFromBundledPluginsDir(bundledPluginsDir);
}
return OPENCLAW_PACKAGE_ROOT;
}
function resolveChannelPluginModuleEntry(
moduleExport: unknown,
): BundledChannelEntryRuntimeContract | null {
@@ -103,16 +125,12 @@ function resolveBundledChannelBoundaryRoot(params: {
metadata: BundledChannelPluginMetadata;
modulePath: string;
}): string {
const distRoot = path.resolve(
OPENCLAW_PACKAGE_ROOT,
"dist",
"extensions",
params.metadata.dirName,
);
const packageRoot = resolveBundledChannelPackageRoot();
const distRoot = path.resolve(packageRoot, "dist", "extensions", params.metadata.dirName);
if (params.modulePath === distRoot || params.modulePath.startsWith(`${distRoot}${path.sep}`)) {
return distRoot;
}
return path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.metadata.dirName);
return path.resolve(packageRoot, "extensions", params.metadata.dirName);
}
function resolveGeneratedBundledChannelModulePath(params: {
@@ -122,8 +140,9 @@ function resolveGeneratedBundledChannelModulePath(params: {
if (!params.entry) {
return null;
}
const packageRoot = resolveBundledChannelPackageRoot();
const resolved = resolveBundledChannelGeneratedPath(
OPENCLAW_PACKAGE_ROOT,
packageRoot,
params.entry,
params.metadata.dirName,
);
@@ -194,14 +213,21 @@ function loadGeneratedBundledChannelEntry(params: {
}
}
let cachedBundledChannelMetadata: readonly BundledChannelPluginMetadata[] | null = null;
const cachedBundledChannelMetadata = new Map<string, readonly BundledChannelPluginMetadata[]>();
function listBundledChannelMetadata(): readonly BundledChannelPluginMetadata[] {
cachedBundledChannelMetadata ??= listBundledChannelPluginMetadata({
const packageRoot = resolveBundledChannelPackageRoot();
const cached = cachedBundledChannelMetadata.get(packageRoot);
if (cached) {
return cached;
}
const loaded = listBundledChannelPluginMetadata({
rootDir: packageRoot,
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
}).filter((metadata) => (metadata.manifest.channels?.length ?? 0) > 0);
return cachedBundledChannelMetadata;
cachedBundledChannelMetadata.set(packageRoot, loaded);
return loaded;
}
export function listBundledChannelPluginIds(): readonly ChannelId[] {

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,96 @@
import { describe, expect, test } from "vitest";
import { importFreshModule } from "../../test/helpers/import-fresh.ts";
import { createPluginRuntimeStore } from "./runtime-store.js";
describe("createPluginRuntimeStore", () => {
test("shares runtime slots for the same plugin id", () => {
const firstStore = createPluginRuntimeStore<{ value: string }>({
pluginId: "shared-plugin",
errorMessage: "shared plugin runtime not initialized",
});
const secondStore = createPluginRuntimeStore<{ value: string }>({
pluginId: "shared-plugin",
errorMessage: "shared plugin runtime not initialized",
});
firstStore.clearRuntime();
firstStore.setRuntime({ value: "ok" });
expect(secondStore.getRuntime()).toEqual({ value: "ok" });
secondStore.clearRuntime();
expect(firstStore.tryGetRuntime()).toBeNull();
});
test("keeps different plugin ids isolated", () => {
const leftStore = createPluginRuntimeStore<{ value: string }>({
pluginId: "left-plugin",
errorMessage: "left runtime not initialized",
});
const rightStore = createPluginRuntimeStore<{ value: string }>({
pluginId: "right-plugin",
errorMessage: "right runtime not initialized",
});
leftStore.clearRuntime();
rightStore.clearRuntime();
leftStore.setRuntime({ value: "left" });
expect(leftStore.getRuntime()).toEqual({ value: "left" });
expect(rightStore.tryGetRuntime()).toBeNull();
});
test("keeps legacy string callers working", () => {
const firstStore = createPluginRuntimeStore<{ value: string }>(
"legacy runtime not initialized",
);
const secondStore = createPluginRuntimeStore<{ value: string }>(
"legacy runtime not initialized",
);
firstStore.clearRuntime();
firstStore.setRuntime({ value: "legacy" });
expect(secondStore.getRuntime()).toEqual({ value: "legacy" });
});
test("still supports explicit custom store keys", () => {
const firstStore = createPluginRuntimeStore<{ value: string }>({
key: "custom-runtime-key",
errorMessage: "custom runtime not initialized",
});
const secondStore = createPluginRuntimeStore<{ value: string }>({
key: "custom-runtime-key",
errorMessage: "custom runtime not initialized",
});
firstStore.clearRuntime();
firstStore.setRuntime({ value: "custom" });
expect(secondStore.getRuntime()).toEqual({ value: "custom" });
});
test("shares runtime slots across duplicate module instances when plugin id matches", async () => {
const firstModule = await importFreshModule<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,87 @@
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 {
return `plugin-runtime:${pluginId.trim()}`;
}
function resolvePluginRuntimeStoreOptions(
options: string | PluginRuntimeStoreOptions,
): PluginRuntimeStoreKeyOptions {
if (typeof options === "string") {
return { key: options, errorMessage: options };
}
if ("pluginId" in options) {
return {
key: pluginRuntimeStoreKeyForPluginId(options.pluginId),
errorMessage: options.errorMessage,
};
}
return options;
}
/** Create a tiny mutable runtime slot with strict access when the runtime has not been initialized. */
export function createPluginRuntimeStore<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 registry = getPluginRuntimeStoreRegistry();
let slot = registry.get(resolved.key);
if (!slot) {
slot = { runtime: null };
registry.set(resolved.key, slot);
}
return {
setRuntime(next: T) {
runtime = next;
slot.runtime = next;
},
clearRuntime() {
runtime = null;
slot.runtime = null;
},
tryGetRuntime() {
return runtime;
return (slot.runtime as T | null) ?? null;
},
getRuntime() {
if (!runtime) {
throw new Error(errorMessage);
if (!slot.runtime) {
throw new Error(resolved.errorMessage);
}
return runtime;
return slot.runtime as T;
},
};
}

View File

@@ -522,8 +522,11 @@ function createSetupEntryChannelPluginFixture(params: {
setupBlurb: string;
configured: boolean;
startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean;
useBundledFullEntryContract?: boolean;
useBundledSetupEntryContract?: boolean;
splitBundledSetupSecrets?: boolean;
bundledSetupRuntimeMarker?: string;
bundledFullRuntimeMarker?: string;
}) {
useNoBundledPlugins();
const pluginDir = makeTempDir();
@@ -571,7 +574,39 @@ function createSetupEntryChannelPluginFixture(params: {
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
params.useBundledFullEntryContract
? `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
kind: "bundled-channel-entry",
id: ${JSON.stringify(params.id)},
name: ${JSON.stringify(params.label)},
description: ${JSON.stringify(params.fullBlurb)},
loadChannelPlugin: () => ({
id: ${JSON.stringify(params.id)},
meta: {
id: ${JSON.stringify(params.id)},
label: ${JSON.stringify(params.label)},
selectionLabel: ${JSON.stringify(params.label)},
docsPath: ${JSON.stringify(`/channels/${params.id}`)},
blurb: ${JSON.stringify(params.fullBlurb)},
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ${listAccountIds},
resolveAccount: () => ${resolveAccount},
},
outbound: { deliveryMode: "direct" },
}),
${
params.bundledFullRuntimeMarker
? `setChannelRuntime: () => {
require("node:fs").writeFileSync(${JSON.stringify(params.bundledFullRuntimeMarker)}, "loaded", "utf-8");
},`
: ""
}
register() {},
};`
: `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: ${JSON.stringify(params.id)},
register(api) {
@@ -631,6 +666,13 @@ module.exports = {
}),`
: ""
}
${
params.bundledSetupRuntimeMarker
? `setChannelRuntime: () => {
require("node:fs").writeFileSync(${JSON.stringify(params.bundledSetupRuntimeMarker)}, "loaded", "utf-8");
},`
: ""
}
};`
: `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
module.exports = {
@@ -3268,7 +3310,7 @@ module.exports = {
},
},
}),
expectFullLoaded: false,
expectFullLoaded: true,
expectSetupLoaded: true,
expectedChannels: 1,
},
@@ -3294,11 +3336,66 @@ module.exports = {
},
},
}),
expectFullLoaded: false,
expectFullLoaded: true,
expectSetupLoaded: true,
expectedChannels: 1,
expectedSetupSecretId: "channels.setup-runtime-bundled-contract-secrets-test.setup-token",
},
{
name: "applies bundled setupEntry runtime setter for setup-runtime channel loads",
fixture: {
id: "setup-runtime-bundled-contract-runtime-test",
label: "Setup Runtime Bundled Contract Runtime Test",
packageName: "@openclaw/setup-runtime-bundled-contract-runtime-test",
fullBlurb: "full entry should not run while unconfigured",
setupBlurb: "setup runtime bundled contract runtime",
configured: false,
useBundledSetupEntryContract: true,
bundledSetupRuntimeMarker: path.join(makeTempDir(), "setup-runtime-applied.txt"),
},
load: ({ pluginDir }: { pluginDir: string }) =>
loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [pluginDir] },
allow: ["setup-runtime-bundled-contract-runtime-test"],
},
},
}),
expectFullLoaded: true,
expectSetupLoaded: true,
expectedChannels: 1,
expectSetupRuntimeLoaded: true,
},
{
name: "merges bundled runtime plugin into setup-runtime channel loads",
fixture: {
id: "setup-runtime-bundled-runtime-merge-test",
label: "Setup Runtime Bundled Runtime Merge Test",
packageName: "@openclaw/setup-runtime-bundled-runtime-merge-test",
fullBlurb: "full runtime plugin",
setupBlurb: "setup runtime override",
configured: false,
useBundledFullEntryContract: true,
useBundledSetupEntryContract: true,
bundledFullRuntimeMarker: path.join(makeTempDir(), "bundled-runtime-applied.txt"),
},
load: ({ pluginDir }: { pluginDir: string }) =>
loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [pluginDir] },
allow: ["setup-runtime-bundled-runtime-merge-test"],
},
},
}),
expectFullLoaded: true,
expectSetupLoaded: true,
expectedChannels: 1,
expectBundledFullRuntimeLoaded: true,
},
{
name: "does not prefer setupEntry for configured channel loads without startup opt-in",
fixture: {
@@ -3339,6 +3436,8 @@ module.exports = {
expectSetupLoaded,
expectedChannels,
expectedSetupSecretId,
expectSetupRuntimeLoaded,
expectBundledFullRuntimeLoaded,
}) => {
const built = createSetupEntryChannelPluginFixture(fixture);
const registry = load({ pluginDir: built.pluginDir });
@@ -3347,6 +3446,16 @@ module.exports = {
expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded);
expect(registry.channelSetups).toHaveLength(1);
expect(registry.channels).toHaveLength(expectedChannels);
if (fixture.bundledSetupRuntimeMarker) {
expect(fs.existsSync(fixture.bundledSetupRuntimeMarker)).toBe(
expectSetupRuntimeLoaded ?? false,
);
}
if (fixture.bundledFullRuntimeMarker) {
expect(fs.existsSync(fixture.bundledFullRuntimeMarker)).toBe(
expectBundledFullRuntimeLoaded ?? false,
);
}
if (expectedSetupSecretId) {
expect(registry.channelSetups[0]?.plugin.secrets?.secretTargetRegistryEntries).toEqual(
expect.arrayContaining([

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,83 @@ 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): {
plugin?: ChannelPlugin;
setChannelRuntime?: (runtime: PluginRuntime) => void;
loadError?: unknown;
} {
const resolved = unwrapDefaultModuleExport(moduleExport);
if (!resolved || typeof resolved !== "object") {
return {};
}
const entryRecord = resolved as {
kind?: unknown;
loadChannelPlugin?: unknown;
loadChannelSecrets?: unknown;
setChannelRuntime?: unknown;
};
if (
entryRecord.kind !== "bundled-channel-entry" ||
typeof entryRecord.loadChannelPlugin !== "function"
) {
return {};
}
try {
const loadedPlugin = entryRecord.loadChannelPlugin();
const loadedSecrets =
typeof entryRecord.loadChannelSecrets === "function"
? (entryRecord.loadChannelSecrets() as ChannelPlugin["secrets"] | undefined)
: undefined;
if (loadedPlugin && typeof loadedPlugin === "object") {
const mergedSecrets = mergeChannelPluginSection(
(loadedPlugin as ChannelPlugin).secrets,
loadedSecrets,
);
return {
plugin: {
...(loadedPlugin as ChannelPlugin),
...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}),
},
...(typeof entryRecord.setChannelRuntime === "function"
? {
setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void,
}
: {}),
};
}
} catch (err) {
return { loadError: err };
}
return {};
}
function resolveSetupChannelRegistration(moduleExport: unknown): {
plugin?: ChannelPlugin;
setChannelRuntime?: (runtime: PluginRuntime) => void;
usesBundledSetupContract?: boolean;
loadError?: unknown;
} {
const resolved = unwrapDefaultModuleExport(moduleExport);
@@ -670,6 +747,7 @@ function resolveSetupChannelRegistration(moduleExport: unknown): {
kind?: unknown;
loadSetupPlugin?: unknown;
loadSetupSecrets?: unknown;
setChannelRuntime?: unknown;
};
if (
setupEntryRecord.kind === "bundled-channel-setup-entry" &&
@@ -682,7 +760,7 @@ function resolveSetupChannelRegistration(moduleExport: unknown): {
? (setupEntryRecord.loadSetupSecrets() as ChannelPlugin["secrets"] | undefined)
: undefined;
if (loadedPlugin && typeof loadedPlugin === "object") {
const mergedSecrets = mergeSetupPluginSection(
const mergedSecrets = mergeChannelPluginSection(
(loadedPlugin as ChannelPlugin).secrets,
loadedSecrets,
);
@@ -691,6 +769,14 @@ function resolveSetupChannelRegistration(moduleExport: unknown): {
...(loadedPlugin as ChannelPlugin),
...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}),
},
usesBundledSetupContract: true,
...(typeof setupEntryRecord.setChannelRuntime === "function"
? {
setChannelRuntime: setupEntryRecord.setChannelRuntime as (
runtime: PluginRuntime,
) => void,
}
: {}),
};
}
} catch (err) {
@@ -1697,9 +1783,81 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (setupRegistration.plugin) {
if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) {
let mergedSetupRegistration = setupRegistration;
if (setupRegistration.usesBundledSetupContract && candidate.source !== safeSource) {
const runtimeOpened = openBoundaryFileSync({
absolutePath: candidate.source,
rootPath: pluginRoot,
boundaryLabel: "plugin root",
rejectHardlinks: candidate.origin !== "bundled",
skipLexicalRootCheck: true,
});
if (!runtimeOpened.ok) {
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
continue;
}
const safeRuntimeSource = runtimeOpened.path;
fs.closeSync(runtimeOpened.fd);
const safeRuntimeImportSource = toSafeImportPath(safeRuntimeSource);
let runtimeMod: OpenClawPluginModule | null = null;
try {
runtimeMod = profilePluginLoaderSync({
phase: "load-setup-runtime-entry",
pluginId: record.id,
source: safeRuntimeSource,
run: () =>
getJiti(safeRuntimeSource)(safeRuntimeImportSource) as OpenClawPluginModule,
});
} catch (err) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
phase: "load",
error: err,
logPrefix: `[plugins] ${record.id} failed to load setup-runtime entry from ${record.source}: `,
diagnosticMessagePrefix: "failed to load setup-runtime entry: ",
});
continue;
}
const runtimeRegistration = resolveBundledRuntimeChannelRegistration(runtimeMod);
if (runtimeRegistration.loadError) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
phase: "load",
error: runtimeRegistration.loadError,
logPrefix: `[plugins] ${record.id} failed to load setup-runtime channel entry from ${record.source}: `,
diagnosticMessagePrefix: "failed to load setup-runtime channel entry: ",
});
continue;
}
if (runtimeRegistration.plugin) {
mergedSetupRegistration = {
...setupRegistration,
plugin: mergeSetupRuntimeChannelPlugin(
runtimeRegistration.plugin,
setupRegistration.plugin,
),
setChannelRuntime:
runtimeRegistration.setChannelRuntime ?? setupRegistration.setChannelRuntime,
};
}
}
const mergedSetupPlugin = mergedSetupRegistration.plugin;
if (!mergedSetupPlugin) {
continue;
}
if (mergedSetupPlugin.id && mergedSetupPlugin.id !== record.id) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`,
`plugin id mismatch (config uses "${record.id}", setup export uses "${mergedSetupPlugin.id}")`,
);
continue;
}
@@ -1709,7 +1867,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
hookPolicy: entry?.hooks,
registrationMode,
});
api.registerChannel(setupRegistration.plugin);
mergedSetupRegistration.setChannelRuntime?.(api.runtime);
api.registerChannel(mergedSetupPlugin);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;