From f88c330657497781555f3dbc5e48a4b47ab7cbad Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 27 Apr 2026 13:52:35 +0100 Subject: [PATCH] fix: preserve runtime config during source plugin activation --- .../plugin-activation-runtime-config.ts | 105 ++++++++++++++++++ src/gateway/server-plugin-bootstrap.ts | 18 +-- src/gateway/server-plugins.test.ts | 105 ++++++++++++++++++ src/gateway/server-startup-plugins.test.ts | 92 ++++++++++++++- src/gateway/server-startup-plugins.ts | 18 +-- 5 files changed, 303 insertions(+), 35 deletions(-) create mode 100644 src/gateway/plugin-activation-runtime-config.ts diff --git a/src/gateway/plugin-activation-runtime-config.ts b/src/gateway/plugin-activation-runtime-config.ts new file mode 100644 index 00000000000..7974d3d2dcf --- /dev/null +++ b/src/gateway/plugin-activation-runtime-config.ts @@ -0,0 +1,105 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord } from "../utils.js"; + +function hasOwnValue(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function mergeChannelActivationSections(params: { + runtimeConfig: OpenClawConfig; + activationConfig: OpenClawConfig; +}): OpenClawConfig { + const activationChannels = params.activationConfig.channels; + if (!isRecord(activationChannels)) { + return params.runtimeConfig; + } + + const runtimeChannels = isRecord(params.runtimeConfig.channels) + ? params.runtimeConfig.channels + : {}; + let nextChannels: Record | undefined; + + for (const [channelId, activationChannel] of Object.entries(activationChannels)) { + if (!isRecord(activationChannel) || !hasOwnValue(activationChannel, "enabled")) { + continue; + } + const runtimeChannel = runtimeChannels[channelId]; + const runtimeChannelRecord = isRecord(runtimeChannel) ? runtimeChannel : {}; + nextChannels ??= { ...runtimeChannels }; + nextChannels[channelId] = { + ...runtimeChannelRecord, + enabled: activationChannel.enabled, + }; + } + + if (nextChannels === undefined) { + return params.runtimeConfig; + } + return { + ...params.runtimeConfig, + channels: nextChannels as OpenClawConfig["channels"], + }; +} + +function mergePluginActivationSections(params: { + runtimeConfig: OpenClawConfig; + activationConfig: OpenClawConfig; +}): OpenClawConfig { + const activationPlugins = params.activationConfig.plugins; + if (!isRecord(activationPlugins)) { + return params.runtimeConfig; + } + + const runtimePlugins = isRecord(params.runtimeConfig.plugins) ? params.runtimeConfig.plugins : {}; + let nextPlugins: Record | undefined; + + if (Array.isArray(activationPlugins.allow)) { + nextPlugins = { + ...runtimePlugins, + allow: [...activationPlugins.allow], + }; + } + + const activationEntries = activationPlugins.entries; + if (isRecord(activationEntries)) { + const runtimeEntries = isRecord(runtimePlugins.entries) ? runtimePlugins.entries : {}; + let nextEntries: Record | undefined; + for (const [pluginId, activationEntry] of Object.entries(activationEntries)) { + if (!isRecord(activationEntry) || !hasOwnValue(activationEntry, "enabled")) { + continue; + } + const runtimeEntry = runtimeEntries[pluginId]; + const runtimeEntryRecord = isRecord(runtimeEntry) ? runtimeEntry : {}; + nextEntries ??= { ...runtimeEntries }; + nextEntries[pluginId] = { + ...runtimeEntryRecord, + enabled: activationEntry.enabled, + }; + } + if (nextEntries !== undefined) { + nextPlugins = { + ...runtimePlugins, + ...nextPlugins, + entries: nextEntries, + }; + } + } + + if (nextPlugins === undefined) { + return params.runtimeConfig; + } + return { + ...params.runtimeConfig, + plugins: nextPlugins as OpenClawConfig["plugins"], + }; +} + +export function mergeActivationSectionsIntoRuntimeConfig(params: { + runtimeConfig: OpenClawConfig; + activationConfig: OpenClawConfig; +}): OpenClawConfig { + return mergePluginActivationSections({ + ...params, + runtimeConfig: mergeChannelActivationSections(params), + }); +} diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 01f648be127..d114593ea40 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -9,6 +9,7 @@ import { setGatewayNodesRuntime, setGatewaySubagentRuntime, } from "../plugins/runtime/gateway-bindings.js"; +import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js"; import type { GatewayRequestHandler } from "./server-methods/types.js"; import { createGatewayNodesRuntime, @@ -47,21 +48,6 @@ function installGatewayPluginRuntimeEnvironment(cfg: OpenClawConfig) { setGatewayNodesRuntime(createGatewayNodesRuntime()); } -function applyActivationSectionsToRuntimeConfig(params: { - runtimeConfig: OpenClawConfig; - activationConfig: OpenClawConfig; -}): OpenClawConfig { - return { - ...params.runtimeConfig, - ...(params.activationConfig.channels !== undefined - ? { channels: params.activationConfig.channels } - : {}), - ...(params.activationConfig.plugins !== undefined - ? { plugins: params.activationConfig.plugins } - : {}), - }; -} - function logGatewayPluginDiagnostics(params: { diagnostics: PluginRegistry["diagnostics"]; log: Pick; @@ -96,7 +82,7 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { const resolvedConfig = activationSourceConfig === params.cfg ? autoEnabled.config - : applyActivationSectionsToRuntimeConfig({ + : mergeActivationSectionsIntoRuntimeConfig({ runtimeConfig: params.cfg, activationConfig: autoEnabled.config, }); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 3467386a097..41e97863b1f 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -506,6 +506,111 @@ describe("loadGatewayPlugins", () => { ); }); + test("preserves runtime defaults while applying source activation to startup loads", async () => { + const rawConfig = { + channels: { + telegram: { + botToken: "token", + }, + }, + plugins: { + allow: ["bench-plugin"], + }, + }; + const runtimeConfig = { + channels: { + telegram: { + botToken: "token", + dmPolicy: "pairing" as const, + groupPolicy: "allowlist" as const, + }, + }, + plugins: { + allow: ["bench-plugin", "memory-core"], + entries: { + "bench-plugin": { + config: { + runtimeDefault: true, + }, + }, + "memory-core": { + config: { + dreaming: { + enabled: false, + }, + }, + }, + }, + }, + }; + const activationConfig = { + channels: { + telegram: { + botToken: "token", + enabled: true, + }, + }, + plugins: { + allow: ["bench-plugin"], + entries: { + "bench-plugin": { + enabled: true, + }, + }, + }, + }; + applyPluginAutoEnable.mockReturnValue({ + config: activationConfig, + changes: [], + autoEnabledReasons: { + telegram: ["telegram configured"], + }, + }); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + loadGatewayStartupPluginsForTest({ + cfg: runtimeConfig, + activationSourceConfig: rawConfig, + pluginIds: ["telegram"], + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + channels: expect.objectContaining({ + telegram: expect.objectContaining({ + enabled: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + }), + }), + plugins: expect.objectContaining({ + allow: ["bench-plugin"], + entries: expect.objectContaining({ + "bench-plugin": expect.objectContaining({ + enabled: true, + config: { + runtimeDefault: true, + }, + }), + "memory-core": { + config: { + dreaming: { + enabled: false, + }, + }, + }, + }), + }), + }), + activationSourceConfig: rawConfig, + autoEnabledReasons: { + telegram: ["telegram configured"], + }, + }), + ); + }); + test("treats an empty startup scope as no plugin load instead of an unscoped load", async () => { loadPluginLookUpTable.mockReturnValue({ startup: { diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index 60adec919c9..5e2bdd6e2eb 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -196,14 +196,47 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { it("derives startup activation from source config instead of runtime plugin defaults", async () => { const sourceConfig = { + channels: { + telegram: { + botToken: "token", + }, + }, plugins: { allow: ["bench-plugin"], }, } as OpenClawConfig; - const runtimeConfig = { + const activationConfig = { + channels: { + telegram: { + botToken: "token", + enabled: true, + }, + }, plugins: { allow: ["bench-plugin"], entries: { + "bench-plugin": { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + channels: { + telegram: { + botToken: "token", + dmPolicy: "pairing", + groupPolicy: "allowlist", + }, + }, + plugins: { + allow: ["bench-plugin", "memory-core"], + entries: { + "bench-plugin": { + config: { + runtimeDefault: true, + }, + }, "memory-core": { config: { dreaming: { @@ -214,6 +247,11 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { }, }, } as OpenClawConfig; + applyPluginAutoEnable.mockReturnValueOnce({ + config: activationConfig, + changes: [], + autoEnabledReasons: {}, + }); const log = createLog(); const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); @@ -233,7 +271,31 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { expect.objectContaining({ activationSourceConfig: sourceConfig, config: expect.objectContaining({ - plugins: sourceConfig.plugins, + channels: expect.objectContaining({ + telegram: expect.objectContaining({ + enabled: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + }), + }), + plugins: expect.objectContaining({ + allow: ["bench-plugin"], + entries: expect.objectContaining({ + "bench-plugin": expect.objectContaining({ + enabled: true, + config: { + runtimeDefault: true, + }, + }), + "memory-core": { + config: { + dreaming: { + enabled: false, + }, + }, + }, + }), + }), }), }), ); @@ -241,7 +303,31 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { expect.objectContaining({ activationSourceConfig: sourceConfig, cfg: expect.objectContaining({ - plugins: sourceConfig.plugins, + channels: expect.objectContaining({ + telegram: expect.objectContaining({ + enabled: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + }), + }), + plugins: expect.objectContaining({ + allow: ["bench-plugin"], + entries: expect.objectContaining({ + "bench-plugin": expect.objectContaining({ + enabled: true, + config: { + runtimeDefault: true, + }, + }), + "memory-core": { + config: { + dreaming: { + enabled: false, + }, + }, + }, + }), + }), }), }), ); diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 9fd67521746..7a70b68569e 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -12,6 +12,7 @@ import { import { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; +import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js"; import { listGatewayMethods } from "./server-methods-list.js"; import { loadGatewayStartupPlugins } from "./server-plugin-bootstrap.js"; import { runStartupSessionMigration } from "./server-startup-session-migration.js"; @@ -23,21 +24,6 @@ type GatewayPluginBootstrapLog = { debug: (message: string) => void; }; -function applyActivationSectionsToRuntimeConfig(params: { - runtimeConfig: OpenClawConfig; - activationConfig: OpenClawConfig; -}): OpenClawConfig { - return { - ...params.runtimeConfig, - ...(params.activationConfig.channels !== undefined - ? { channels: params.activationConfig.channels } - : {}), - ...(params.activationConfig.plugins !== undefined - ? { plugins: params.activationConfig.plugins } - : {}), - }; -} - async function prestageGatewayBundledRuntimeDeps(params: { cfg: OpenClawConfig; pluginIds: readonly string[]; @@ -147,7 +133,7 @@ export async function prepareGatewayPluginBootstrap(params: { const gatewayPluginConfig = params.minimalTestGateway ? params.cfgAtStart - : applyActivationSectionsToRuntimeConfig({ + : mergeActivationSectionsIntoRuntimeConfig({ runtimeConfig: params.cfgAtStart, activationConfig: applyPluginAutoEnable({ config: activationSourceConfig,