diff --git a/src/plugin-sdk/runtime-store.test.ts b/src/plugin-sdk/runtime-store.test.ts index 08e0cf1ad45..07ee2b1dd0f 100644 --- a/src/plugin-sdk/runtime-store.test.ts +++ b/src/plugin-sdk/runtime-store.test.ts @@ -70,6 +70,27 @@ describe("createPluginRuntimeStore", () => { expect(secondStore.getRuntime()).toEqual({ value: "custom" }); }); + test("rejects empty plugin ids", () => { + expect(() => + createPluginRuntimeStore({ + pluginId: " ", + errorMessage: "runtime not initialized", + }), + ).toThrow("pluginId must not be empty"); + }); + + test("treats falsy runtime values as initialized", () => { + const store = createPluginRuntimeStore({ + key: "custom-falsy-runtime-key", + errorMessage: "runtime not initialized", + }); + + store.clearRuntime(); + store.setRuntime(0); + + expect(store.getRuntime()).toBe(0); + }); + test("shares runtime slots across duplicate module instances when plugin id matches", async () => { const firstModule = await importFreshModule( import.meta.url, diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts index ddaf8c5ef15..7f872f4ad92 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -22,7 +22,11 @@ function getPluginRuntimeStoreRegistry(): PluginRuntimeStoreRegistry { } function pluginRuntimeStoreKeyForPluginId(pluginId: string): string { - return `plugin-runtime:${pluginId.trim()}`; + const normalizedPluginId = pluginId.trim(); + if (!normalizedPluginId) { + throw new Error("createPluginRuntimeStore: pluginId must not be empty"); + } + return `plugin-runtime:${normalizedPluginId}`; } function resolvePluginRuntimeStoreOptions( @@ -78,7 +82,7 @@ export function createPluginRuntimeStore(options: string | PluginRuntimeStore return (slot.runtime as T | null) ?? null; }, getRuntime() { - if (!slot.runtime) { + if (slot.runtime === null) { throw new Error(resolved.errorMessage); } return slot.runtime as T; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index e96c2a877bd..b9ef5eec132 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -526,7 +526,9 @@ function createSetupEntryChannelPluginFixture(params: { useBundledSetupEntryContract?: boolean; splitBundledSetupSecrets?: boolean; bundledSetupRuntimeMarker?: string; + bundledSetupRuntimeError?: string; bundledFullRuntimeMarker?: string; + requireBundledFullRuntimeBeforeLoad?: boolean; }) { useNoBundledPlugins(); const pluginDir = makeTempDir(); @@ -581,22 +583,31 @@ module.exports = { id: ${JSON.stringify(params.id)}, name: ${JSON.stringify(params.label)}, description: ${JSON.stringify(params.fullBlurb)}, - loadChannelPlugin: () => ({ - id: ${JSON.stringify(params.id)}, - meta: { + 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.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" }, - }), + 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: () => { @@ -667,11 +678,15 @@ module.exports = { : "" } ${ - params.bundledSetupRuntimeMarker + 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"); @@ -3265,6 +3280,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: { @@ -3475,6 +3520,75 @@ 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("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => { useNoBundledPlugins(); const pluginDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 1defa0c9ca3..726a7187d4e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -684,9 +684,9 @@ function mergeSetupRuntimeChannelPlugin( } function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { - plugin?: ChannelPlugin; + loadChannelPlugin?: () => ChannelPlugin; + loadChannelSecrets?: () => ChannelPlugin["secrets"] | undefined; setChannelRuntime?: (runtime: PluginRuntime) => void; - loadError?: unknown; } { const resolved = unwrapDefaultModuleExport(moduleExport); if (!resolved || typeof resolved !== "object") { @@ -704,33 +704,48 @@ function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { ) { return {}; } + return { + loadChannelPlugin: entryRecord.loadChannelPlugin as () => ChannelPlugin, + ...(typeof entryRecord.loadChannelSecrets === "function" + ? { + loadChannelSecrets: entryRecord.loadChannelSecrets as () => + | ChannelPlugin["secrets"] + | undefined, + } + : {}), + ...(typeof entryRecord.setChannelRuntime === "function" + ? { + setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void, + } + : {}), + }; +} + +function loadBundledRuntimeChannelPlugin(params: { + registration: ReturnType; +}): { + plugin?: ChannelPlugin; + loadError?: unknown; +} { + if (typeof params.registration.loadChannelPlugin !== "function") { + return {}; + } try { - const loadedPlugin = 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, - } - : {}), - }; + 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 }; } - return {}; } function resolveSetupChannelRegistration(moduleExport: unknown): { @@ -1783,8 +1798,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } if (setupRegistration.plugin) { + const api = createApi(record, { + config: cfg, + pluginConfig: {}, + hookPolicy: entry?.hooks, + registrationMode, + }); let mergedSetupRegistration = setupRegistration; - if (setupRegistration.usesBundledSetupContract && candidate.source !== safeSource) { + let runtimeSetterApplied = false; + if ( + registrationMode === "setup-runtime" && + setupRegistration.usesBundledSetupContract && + candidate.source !== safeSource + ) { const runtimeOpened = openBoundaryFileSync({ absolutePath: candidate.source, rootPath: pluginRoot, @@ -1824,7 +1850,30 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } const runtimeRegistration = resolveBundledRuntimeChannelRegistration(runtimeMod); - if (runtimeRegistration.loadError) { + 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, @@ -1833,17 +1882,17 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi pluginId, origin: candidate.origin, phase: "load", - error: runtimeRegistration.loadError, + 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 (runtimeRegistration.plugin) { + if (runtimePluginRegistration.plugin) { mergedSetupRegistration = { ...setupRegistration, plugin: mergeSetupRuntimeChannelPlugin( - runtimeRegistration.plugin, + runtimePluginRegistration.plugin, setupRegistration.plugin, ), setChannelRuntime: @@ -1861,13 +1910,25 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi ); continue; } - const api = createApi(record, { - config: cfg, - pluginConfig: {}, - hookPolicy: entry?.hooks, - registrationMode, - }); - mergedSetupRegistration.setChannelRuntime?.(api.runtime); + 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);