mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:00:50 +00:00
fix(plugins): stabilize bundled setup runtimes
This commit is contained in:
@@ -493,6 +493,11 @@ should use `resolveInboundMentionDecision({ facts, policy })`.
|
||||
or unconfigured. It avoids pulling in heavy runtime code during setup flows.
|
||||
See [Setup and Config](/plugins/sdk-setup#setup-entry) for details.
|
||||
|
||||
Bundled workspace channels that split setup-safe exports into sidecar
|
||||
modules can use `defineBundledChannelSetupEntry(...)` from
|
||||
`openclaw/plugin-sdk/channel-entry-contract` when they also need an
|
||||
explicit setup-time runtime setter.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Handle inbound messages">
|
||||
|
||||
@@ -145,6 +145,31 @@ families:
|
||||
Keep heavy SDKs, CLI registration, and long-lived runtime services in the full
|
||||
entry.
|
||||
|
||||
Bundled workspace channels that split setup and runtime surfaces can use
|
||||
`defineBundledChannelSetupEntry(...)` from
|
||||
`openclaw/plugin-sdk/channel-entry-contract` instead. That contract lets the
|
||||
setup entry keep setup-safe plugin/secrets exports while still exposing a
|
||||
runtime setter:
|
||||
|
||||
```typescript
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "myChannelPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setMyChannelRuntime",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use that bundled contract only when setup flows truly need a lightweight runtime
|
||||
setter before the full channel entry loads.
|
||||
|
||||
## Registration mode
|
||||
|
||||
`api.registrationMode` tells your plugin how it was loaded:
|
||||
|
||||
@@ -385,7 +385,10 @@ the `register` callback:
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const store = createPluginRuntimeStore<PluginRuntime>("my-plugin runtime not initialized");
|
||||
const store = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "my-plugin",
|
||||
errorMessage: "my-plugin runtime not initialized",
|
||||
});
|
||||
|
||||
// In your entry point
|
||||
export default defineChannelPluginEntry({
|
||||
@@ -406,6 +409,10 @@ export function tryGetRuntime() {
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `pluginId` for the runtime-store identity. The lower-level `key` form is
|
||||
for uncommon cases where one plugin intentionally needs more than one runtime
|
||||
slot.
|
||||
|
||||
## Other top-level `api` fields
|
||||
|
||||
Beyond `api.runtime`, the API object also provides:
|
||||
|
||||
@@ -279,6 +279,12 @@ export default defineSetupPluginEntry(myChannelPlugin);
|
||||
This avoids loading heavy runtime code (crypto libraries, CLI registrations,
|
||||
background services) during setup flows.
|
||||
|
||||
Bundled workspace channels that keep setup-safe exports in sidecar modules can
|
||||
use `defineBundledChannelSetupEntry(...)` from
|
||||
`openclaw/plugin-sdk/channel-entry-contract` instead of
|
||||
`defineSetupPluginEntry(...)`. That bundled contract also supports an optional
|
||||
`runtime` export so setup-time runtime wiring can stay lightweight and explicit.
|
||||
|
||||
**When OpenClaw uses `setupEntry` instead of the full entry:**
|
||||
|
||||
- The channel is disabled but needs setup/onboarding surfaces
|
||||
|
||||
@@ -155,7 +155,10 @@ For code that uses `createPluginRuntimeStore`, mock the runtime in tests:
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const store = createPluginRuntimeStore<PluginRuntime>("test runtime not set");
|
||||
const store = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "test-plugin",
|
||||
errorMessage: "test runtime not set",
|
||||
});
|
||||
|
||||
// In test setup
|
||||
const mockRuntime = {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "bluebubbles",
|
||||
errorMessage: "BlueBubbles runtime not initialized",
|
||||
});
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
|
||||
|
||||
|
||||
@@ -16,5 +16,8 @@ const {
|
||||
setRuntime: setDiscordRuntime,
|
||||
tryGetRuntime: getOptionalDiscordRuntime,
|
||||
getRuntime: getDiscordRuntime,
|
||||
} = createPluginRuntimeStore<DiscordRuntime>("Discord runtime not initialized");
|
||||
} = createPluginRuntimeStore<DiscordRuntime>({
|
||||
pluginId: "discord",
|
||||
errorMessage: "Discord runtime not initialized",
|
||||
});
|
||||
export { getDiscordRuntime, getOptionalDiscordRuntime, setDiscordRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "feishu",
|
||||
errorMessage: "Feishu runtime not initialized",
|
||||
});
|
||||
export { getFeishuRuntime, setFeishuRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Google Chat runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "googlechat",
|
||||
errorMessage: "Google Chat runtime not initialized",
|
||||
});
|
||||
export { getGoogleChatRuntime, setGoogleChatRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "imessage",
|
||||
errorMessage: "iMessage runtime not initialized",
|
||||
});
|
||||
export { getIMessageRuntime, setIMessageRuntime };
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("IRC runtime not initialized");
|
||||
const {
|
||||
setRuntime: setIrcRuntime,
|
||||
clearRuntime: clearStoredIrcRuntime,
|
||||
getRuntime: getIrcRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "irc",
|
||||
errorMessage: "IRC runtime not initialized",
|
||||
});
|
||||
export { getIrcRuntime, setIrcRuntime };
|
||||
export function clearIrcRuntime() {
|
||||
setIrcRuntime(undefined as unknown as PluginRuntime);
|
||||
clearStoredIrcRuntime();
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@ const {
|
||||
setRuntime: setLineRuntime,
|
||||
clearRuntime: clearLineRuntime,
|
||||
getRuntime: getLineRuntime,
|
||||
} = createPluginRuntimeStore<LineRuntime>("LINE runtime not initialized - plugin not registered");
|
||||
} = createPluginRuntimeStore<LineRuntime>({
|
||||
pluginId: "line",
|
||||
errorMessage: "LINE runtime not initialized - plugin not registered",
|
||||
});
|
||||
export { clearLineRuntime, getLineRuntime, setLineRuntime };
|
||||
|
||||
@@ -10,4 +10,8 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setMatrixRuntime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Matrix runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "matrix",
|
||||
errorMessage: "Matrix runtime not initialized",
|
||||
});
|
||||
|
||||
export { getMatrixRuntime, setMatrixRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "mattermost",
|
||||
errorMessage: "Mattermost runtime not initialized",
|
||||
});
|
||||
export { getMattermostRuntime, setMattermostRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("MSTeams runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "msteams",
|
||||
errorMessage: "MSTeams runtime not initialized",
|
||||
});
|
||||
export { getMSTeamsRuntime, setMSTeamsRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Nextcloud Talk runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "nextcloud-talk",
|
||||
errorMessage: "Nextcloud Talk runtime not initialized",
|
||||
});
|
||||
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Nostr runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "nostr",
|
||||
errorMessage: "Nostr runtime not initialized",
|
||||
});
|
||||
export { getNostrRuntime, setNostrRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setQQBotRuntime, getRuntime: getQQBotRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("QQBot runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "qqbot",
|
||||
errorMessage: "QQBot runtime not initialized",
|
||||
});
|
||||
export { getQQBotRuntime, setQQBotRuntime };
|
||||
|
||||
@@ -5,5 +5,8 @@ const {
|
||||
setRuntime: setSignalRuntime,
|
||||
clearRuntime: clearSignalRuntime,
|
||||
getRuntime: getSignalRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "signal",
|
||||
errorMessage: "Signal runtime not initialized",
|
||||
});
|
||||
export { clearSignalRuntime, getSignalRuntime, setSignalRuntime };
|
||||
|
||||
@@ -16,5 +16,8 @@ const {
|
||||
clearRuntime: clearSlackRuntime,
|
||||
tryGetRuntime: getOptionalSlackRuntime,
|
||||
getRuntime: getSlackRuntime,
|
||||
} = createPluginRuntimeStore<SlackRuntime>("Slack runtime not initialized");
|
||||
} = createPluginRuntimeStore<SlackRuntime>({
|
||||
pluginId: "slack",
|
||||
errorMessage: "Slack runtime not initialized",
|
||||
});
|
||||
export { clearSlackRuntime, getOptionalSlackRuntime, getSlackRuntime, setSlackRuntime };
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>(
|
||||
"Synology Chat runtime not initialized - plugin not registered",
|
||||
);
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "synology-chat",
|
||||
errorMessage: "Synology Chat runtime not initialized - plugin not registered",
|
||||
});
|
||||
export { getSynologyRuntime, setSynologyRuntime };
|
||||
|
||||
@@ -6,5 +6,8 @@ const {
|
||||
setRuntime: setTelegramRuntime,
|
||||
clearRuntime: clearTelegramRuntime,
|
||||
getRuntime: getTelegramRuntime,
|
||||
} = createPluginRuntimeStore<TelegramRuntime>("Telegram runtime not initialized");
|
||||
} = createPluginRuntimeStore<TelegramRuntime>({
|
||||
pluginId: "telegram",
|
||||
errorMessage: "Telegram runtime not initialized",
|
||||
});
|
||||
export { clearTelegramRuntime, getTelegramRuntime, setTelegramRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Tlon runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "tlon",
|
||||
errorMessage: "Tlon runtime not initialized",
|
||||
});
|
||||
export { getTlonRuntime, setTlonRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Twitch runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "twitch",
|
||||
errorMessage: "Twitch runtime not initialized",
|
||||
});
|
||||
export { getTwitchRuntime, setTwitchRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "whatsapp",
|
||||
errorMessage: "WhatsApp runtime not initialized",
|
||||
});
|
||||
export { getWhatsAppRuntime, setWhatsAppRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-support.js";
|
||||
|
||||
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalo",
|
||||
errorMessage: "Zalo runtime not initialized",
|
||||
});
|
||||
export { getZaloRuntime, setZaloRuntime };
|
||||
|
||||
@@ -2,5 +2,8 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalouser",
|
||||
errorMessage: "Zalouser runtime not initialized",
|
||||
});
|
||||
export { getZalouserRuntime, setZalouserRuntime };
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
96
src/plugin-sdk/runtime-store.test.ts
Normal file
96
src/plugin-sdk/runtime-store.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user