diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index ebb5d366868..c1f52290c93 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -5,14 +5,37 @@ import { resolveEnableState, } from "./config-state.js"; +function normalizedPlugins(config: Parameters[0]) { + return normalizePluginsConfig(config); +} + +function resolveBundledState(id: string, config: Parameters[0]) { + return resolveEnableState(id, "bundled", normalizedPlugins(config)); +} + +function resolveBundledEffectiveState(config: Parameters[0]) { + return resolveEffectiveEnableState({ + id: "telegram", + origin: "bundled", + config: normalizedPlugins(config), + rootConfig: { + channels: { + telegram: { + enabled: true, + }, + }, + }, + }); +} + describe("normalizePluginsConfig", () => { it("uses default memory slot when not specified", () => { - const result = normalizePluginsConfig({}); + const result = normalizedPlugins({}); expect(result.slots.memory).toBe("memory-core"); }); it("respects explicit memory slot value", () => { - const result = normalizePluginsConfig({ + const result = normalizedPlugins({ slots: { memory: "custom-memory" }, }); expect(result.slots.memory).toBe("custom-memory"); @@ -20,40 +43,40 @@ describe("normalizePluginsConfig", () => { it("disables memory slot when set to 'none' (case insensitive)", () => { expect( - normalizePluginsConfig({ + normalizedPlugins({ slots: { memory: "none" }, }).slots.memory, ).toBeNull(); expect( - normalizePluginsConfig({ + normalizedPlugins({ slots: { memory: "None" }, }).slots.memory, ).toBeNull(); }); it("trims whitespace from memory slot value", () => { - const result = normalizePluginsConfig({ + const result = normalizedPlugins({ slots: { memory: " custom-memory " }, }); expect(result.slots.memory).toBe("custom-memory"); }); it("uses default when memory slot is empty string", () => { - const result = normalizePluginsConfig({ + const result = normalizedPlugins({ slots: { memory: "" }, }); expect(result.slots.memory).toBe("memory-core"); }); it("uses default when memory slot is whitespace only", () => { - const result = normalizePluginsConfig({ + const result = normalizedPlugins({ slots: { memory: " " }, }); expect(result.slots.memory).toBe("memory-core"); }); it("normalizes plugin hook policy flags", () => { - const result = normalizePluginsConfig({ + const result = normalizedPlugins({ entries: { "voice-call": { hooks: { @@ -66,7 +89,7 @@ describe("normalizePluginsConfig", () => { }); it("drops invalid plugin hook policy values", () => { - const result = normalizePluginsConfig({ + const result = normalizedPlugins({ entries: { "voice-call": { hooks: { @@ -80,31 +103,15 @@ describe("normalizePluginsConfig", () => { }); describe("resolveEffectiveEnableState", () => { - function resolveBundledTelegramState(config: Parameters[0]) { - const normalized = normalizePluginsConfig(config); - return resolveEffectiveEnableState({ - id: "telegram", - origin: "bundled", - config: normalized, - rootConfig: { - channels: { - telegram: { - enabled: true, - }, - }, - }, - }); - } - it("enables bundled channels when channels..enabled=true", () => { - const state = resolveBundledTelegramState({ + const state = resolveBundledEffectiveState({ enabled: true, }); expect(state).toEqual({ enabled: true }); }); it("keeps explicit plugin-level disable authoritative", () => { - const state = resolveBundledTelegramState({ + const state = resolveBundledEffectiveState({ enabled: true, entries: { telegram: { @@ -114,35 +121,35 @@ describe("resolveEffectiveEnableState", () => { }); expect(state).toEqual({ enabled: false, reason: "disabled in config" }); }); + + it("does not let channel enablement bypass allowlist misses", () => { + const state = resolveBundledEffectiveState({ + enabled: true, + allow: ["discord"], + }); + expect(state).toEqual({ enabled: false, reason: "not in allowlist" }); + }); }); describe("resolveEnableState", () => { it("keeps the selected memory slot plugin enabled even when omitted from plugins.allow", () => { - const state = resolveEnableState( - "memory-core", - "bundled", - normalizePluginsConfig({ - allow: ["telegram"], - slots: { memory: "memory-core" }, - }), - ); + const state = resolveBundledState("memory-core", { + allow: ["telegram"], + slots: { memory: "memory-core" }, + }); expect(state).toEqual({ enabled: true }); }); it("keeps explicit disable authoritative for the selected memory slot plugin", () => { - const state = resolveEnableState( - "memory-core", - "bundled", - normalizePluginsConfig({ - allow: ["telegram"], - slots: { memory: "memory-core" }, - entries: { - "memory-core": { - enabled: false, - }, + const state = resolveBundledState("memory-core", { + allow: ["telegram"], + slots: { memory: "memory-core" }, + entries: { + "memory-core": { + enabled: false, }, - }), - ); + }, + }); expect(state).toEqual({ enabled: false, reason: "disabled in config" }); }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index e671aae7e2e..9d2d8f085b3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -186,37 +186,123 @@ export function isTestDefaultMemorySlotDisabled( return true; } +type EnableStateCode = + | "plugins_disabled" + | "blocked_by_denylist" + | "disabled_in_config" + | "selected_memory_slot" + | "not_in_allowlist" + | "enabled_in_config" + | "bundled_enabled_by_default" + | "bundled_disabled_by_default" + | "enabled"; + +type EnableStateDecision = { + enabled: boolean; + code: EnableStateCode; +}; + +const ENABLE_STATE_REASON_BY_CODE: Partial> = { + plugins_disabled: "plugins disabled", + blocked_by_denylist: "blocked by denylist", + disabled_in_config: "disabled in config", + not_in_allowlist: "not in allowlist", + bundled_disabled_by_default: "bundled (disabled by default)", +}; + +function finalizeEnableState(decision: EnableStateDecision): { enabled: boolean; reason?: string } { + return { + enabled: decision.enabled, + reason: ENABLE_STATE_REASON_BY_CODE[decision.code], + }; +} + +function resolveExplicitEnableStateDecision(params: { + id: string; + config: NormalizedPluginsConfig; +}): EnableStateDecision | undefined { + if (!params.config.enabled) { + return { enabled: false, code: "plugins_disabled" }; + } + if (params.config.deny.includes(params.id)) { + return { enabled: false, code: "blocked_by_denylist" }; + } + if (params.config.entries[params.id]?.enabled === false) { + return { enabled: false, code: "disabled_in_config" }; + } + return undefined; +} + +function resolveSlotEnableStateDecision(params: { + id: string; + config: NormalizedPluginsConfig; +}): EnableStateDecision | undefined { + if (params.config.slots.memory === params.id) { + return { enabled: true, code: "selected_memory_slot" }; + } + return undefined; +} + +function resolveAllowlistEnableStateDecision(params: { + id: string; + config: NormalizedPluginsConfig; +}): EnableStateDecision | undefined { + if (params.config.allow.length > 0 && !params.config.allow.includes(params.id)) { + return { enabled: false, code: "not_in_allowlist" }; + } + if (params.config.entries[params.id]?.enabled === true) { + return { enabled: true, code: "enabled_in_config" }; + } + return undefined; +} + +function resolveBundledDefaultEnableStateDecision( + id: string, + origin: PluginRecord["origin"], +): EnableStateDecision { + if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { + return { enabled: true, code: "bundled_enabled_by_default" }; + } + if (origin === "bundled") { + return { enabled: false, code: "bundled_disabled_by_default" }; + } + return { enabled: true, code: "enabled" }; +} + +function resolveEnableStateDecision( + id: string, + origin: PluginRecord["origin"], + config: NormalizedPluginsConfig, +): EnableStateDecision { + return ( + resolveExplicitEnableStateDecision({ id, config }) ?? + resolveSlotEnableStateDecision({ id, config }) ?? + resolveAllowlistEnableStateDecision({ id, config }) ?? + resolveBundledDefaultEnableStateDecision(id, origin) + ); +} + +function applyBundledChannelOverride(params: { + id: string; + rootConfig?: OpenClawConfig; + decision: EnableStateDecision; +}): EnableStateDecision { + if ( + !params.decision.enabled && + params.decision.code === "bundled_disabled_by_default" && + isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id) + ) { + return { enabled: true, code: "enabled" }; + } + return params.decision; +} + export function resolveEnableState( id: string, origin: PluginRecord["origin"], config: NormalizedPluginsConfig, ): { enabled: boolean; reason?: string } { - if (!config.enabled) { - return { enabled: false, reason: "plugins disabled" }; - } - if (config.deny.includes(id)) { - return { enabled: false, reason: "blocked by denylist" }; - } - const entry = config.entries[id]; - if (entry?.enabled === false) { - return { enabled: false, reason: "disabled in config" }; - } - if (config.slots.memory === id) { - return { enabled: true }; - } - if (config.allow.length > 0 && !config.allow.includes(id)) { - return { enabled: false, reason: "not in allowlist" }; - } - if (entry?.enabled === true) { - return { enabled: true }; - } - if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { - return { enabled: true }; - } - if (origin === "bundled") { - return { enabled: false, reason: "bundled (disabled by default)" }; - } - return { enabled: true }; + return finalizeEnableState(resolveEnableStateDecision(id, origin, config)); } export function isBundledChannelEnabledByChannelConfig( @@ -244,15 +330,13 @@ export function resolveEffectiveEnableState(params: { config: NormalizedPluginsConfig; rootConfig?: OpenClawConfig; }): { enabled: boolean; reason?: string } { - const base = resolveEnableState(params.id, params.origin, params.config); - if ( - !base.enabled && - base.reason === "bundled (disabled by default)" && - isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id) - ) { - return { enabled: true }; - } - return base; + return finalizeEnableState( + applyBundledChannelOverride({ + id: params.id, + rootConfig: params.rootConfig, + decision: resolveEnableStateDecision(params.id, params.origin, params.config), + }), + ); } export function resolveMemorySlotDecision(params: {