diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index ca799fe05de..c3e7999fe87 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizePluginsConfig, - resolveEnableState, + resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; @@ -36,7 +36,12 @@ export function resolvePluginSkillDirs(params: { if (!record.skills || record.skills.length === 0) { continue; } - const enableState = resolveEnableState(record.id, record.origin, normalizedPlugins); + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.config, + }); if (!enableState.enabled) { continue; } diff --git a/src/cli/plugins-config.test.ts b/src/cli/plugins-config.test.ts index 5ba4c9415b8..3406c22e54d 100644 --- a/src/cli/plugins-config.test.ts +++ b/src/cli/plugins-config.test.ts @@ -29,4 +29,40 @@ describe("setPluginEnabledInConfig", () => { enabled: false, }); }); + + it("keeps built-in channel and plugin entry flags in sync", () => { + const config = { + channels: { + telegram: { + enabled: true, + dmPolicy: "open", + }, + }, + plugins: { + entries: { + telegram: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + + const disabled = setPluginEnabledInConfig(config, "telegram", false); + expect(disabled.channels?.telegram).toEqual({ + enabled: false, + dmPolicy: "open", + }); + expect(disabled.plugins?.entries?.telegram).toEqual({ + enabled: false, + }); + + const reenabled = setPluginEnabledInConfig(disabled, "telegram", true); + expect(reenabled.channels?.telegram).toEqual({ + enabled: true, + dmPolicy: "open", + }); + expect(reenabled.plugins?.entries?.telegram).toEqual({ + enabled: true, + }); + }); }); diff --git a/src/cli/plugins-config.ts b/src/cli/plugins-config.ts index f8634388bfc..7bce40d0a75 100644 --- a/src/cli/plugins-config.ts +++ b/src/cli/plugins-config.ts @@ -1,21 +1 @@ -import type { OpenClawConfig } from "../config/config.js"; - -export function setPluginEnabledInConfig( - config: OpenClawConfig, - pluginId: string, - enabled: boolean, -): OpenClawConfig { - return { - ...config, - plugins: { - ...config.plugins, - entries: { - ...config.plugins?.entries, - [pluginId]: { - ...(config.plugins?.entries?.[pluginId] as object | undefined), - enabled, - }, - }, - }, - }; -} +export { setPluginEnabledInConfig } from "../plugins/toggle-config.js"; diff --git a/src/config/validation.ts b/src/config/validation.ts index 7636a88a31b..f2ee1867485 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -3,7 +3,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js"; import { normalizePluginsConfig, - resolveEnableState, + resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; @@ -373,7 +373,12 @@ function validateConfigObjectWithPluginsBase( const entry = normalizedPlugins.entries[pluginId]; const entryHasConfig = Boolean(entry?.config); - const enableState = resolveEnableState(pluginId, record.origin, normalizedPlugins); + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: record.origin, + config: normalizedPlugins, + rootConfig: config, + }); let enabled = enableState.enabled; let reason = enableState.reason; diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index ad77d44f028..01beb51b8d7 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizePluginsConfig } from "./config-state.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; describe("normalizePluginsConfig", () => { it("uses default memory slot when not specified", () => { @@ -48,3 +48,48 @@ describe("normalizePluginsConfig", () => { expect(result.slots.memory).toBe("memory-core"); }); }); + +describe("resolveEffectiveEnableState", () => { + it("enables bundled channels when channels..enabled=true", () => { + const normalized = normalizePluginsConfig({ + enabled: true, + }); + const state = resolveEffectiveEnableState({ + id: "telegram", + origin: "bundled", + config: normalized, + rootConfig: { + channels: { + telegram: { + enabled: true, + }, + }, + }, + }); + expect(state).toEqual({ enabled: true }); + }); + + it("keeps explicit plugin-level disable authoritative", () => { + const normalized = normalizePluginsConfig({ + enabled: true, + entries: { + telegram: { + enabled: false, + }, + }, + }); + const state = resolveEffectiveEnableState({ + id: "telegram", + origin: "bundled", + config: normalized, + rootConfig: { + channels: { + telegram: { + enabled: true, + }, + }, + }, + }); + expect(state).toEqual({ enabled: false, reason: "disabled in config" }); + }); +}); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 5e41de9a86b..f2626e705ff 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -1,3 +1,4 @@ +import { normalizeChatChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginRecord } from "./registry.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -194,6 +195,42 @@ export function resolveEnableState( return { enabled: true }; } +export function isBundledChannelEnabledByChannelConfig( + cfg: OpenClawConfig | undefined, + pluginId: string, +): boolean { + if (!cfg) { + return false; + } + const channelId = normalizeChatChannelId(pluginId); + if (!channelId) { + return false; + } + const channels = cfg.channels as Record | undefined; + const entry = channels?.[channelId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + return (entry as Record).enabled === true; +} + +export function resolveEffectiveEnableState(params: { + id: string; + origin: PluginRecord["origin"]; + 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; +} + export function resolveMemorySlotDecision(params: { id: string; kind?: string; diff --git a/src/plugins/enable.test.ts b/src/plugins/enable.test.ts index 0934992b830..793ed1c7ffe 100644 --- a/src/plugins/enable.test.ts +++ b/src/plugins/enable.test.ts @@ -32,12 +32,12 @@ describe("enablePluginInConfig", () => { expect(result.reason).toBe("blocked by denylist"); }); - it("writes built-in channels to channels..enabled instead of plugins.entries", () => { + it("writes built-in channels to channels..enabled and plugins.entries", () => { const cfg: OpenClawConfig = {}; const result = enablePluginInConfig(cfg, "telegram"); expect(result.enabled).toBe(true); expect(result.config.channels?.telegram?.enabled).toBe(true); - expect(result.config.plugins?.entries?.telegram).toBeUndefined(); + expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true); }); it("adds built-in channel id to allowlist when allowlist is configured", () => { @@ -51,4 +51,25 @@ describe("enablePluginInConfig", () => { expect(result.config.channels?.telegram?.enabled).toBe(true); expect(result.config.plugins?.allow).toEqual(["memory-core", "telegram"]); }); + + it("re-enables built-in channels after explicit plugin-level disable", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + enabled: true, + }, + }, + plugins: { + entries: { + telegram: { + enabled: false, + }, + }, + }, + }; + const result = enablePluginInConfig(cfg, "telegram"); + expect(result.enabled).toBe(true); + expect(result.config.channels?.telegram?.enabled).toBe(true); + expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true); + }); }); diff --git a/src/plugins/enable.ts b/src/plugins/enable.ts index 55bd8927976..3af13a477ed 100644 --- a/src/plugins/enable.ts +++ b/src/plugins/enable.ts @@ -1,6 +1,7 @@ import { normalizeChatChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js"; +import { setPluginEnabledInConfig } from "./toggle-config.js"; export type PluginEnableResult = { config: OpenClawConfig; @@ -17,41 +18,7 @@ export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): Plu if (cfg.plugins?.deny?.includes(pluginId) || cfg.plugins?.deny?.includes(resolvedId)) { return { config: cfg, enabled: false, reason: "blocked by denylist" }; } - if (builtInChannelId) { - const channels = cfg.channels as Record | undefined; - const existing = channels?.[builtInChannelId]; - const existingRecord = - existing && typeof existing === "object" && !Array.isArray(existing) - ? (existing as Record) - : {}; - let next: OpenClawConfig = { - ...cfg, - channels: { - ...cfg.channels, - [builtInChannelId]: { - ...existingRecord, - enabled: true, - }, - }, - }; - next = ensurePluginAllowlisted(next, resolvedId); - return { config: next, enabled: true }; - } - - const entries = { - ...cfg.plugins?.entries, - [resolvedId]: { - ...(cfg.plugins?.entries?.[resolvedId] as Record | undefined), - enabled: true, - }, - }; - let next: OpenClawConfig = { - ...cfg, - plugins: { - ...cfg.plugins, - entries, - }, - }; + let next = setPluginEnabledInConfig(cfg, resolvedId, true); next = ensurePluginAllowlisted(next, resolvedId); return { config: next, enabled: true }; } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 26491a41816..c6cf256bc68 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; -import { normalizeChatChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -12,7 +11,7 @@ import { clearPluginCommands } from "./commands.js"; import { applyTestPluginDefaults, normalizePluginsConfig, - resolveEnableState, + resolveEffectiveEnableState, resolveMemorySlotDecision, type NormalizedPluginsConfig, } from "./config-state.js"; @@ -176,19 +175,6 @@ function createPluginRecord(params: { }; } -function isBundledChannelEnabledByChannelConfig(cfg: OpenClawConfig, pluginId: string): boolean { - const channelId = normalizeChatChannelId(pluginId); - if (!channelId) { - return false; - } - const channels = cfg.channels as Record | undefined; - const entry = channels?.[channelId]; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return false; - } - return (entry as Record).enabled === true; -} - function recordPluginError(params: { logger: PluginLogger; registry: PluginRegistry; @@ -486,14 +472,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - let enableState = resolveEnableState(pluginId, candidate.origin, normalized); - if ( - !enableState.enabled && - enableState.reason === "bundled (disabled by default)" && - isBundledChannelEnabledByChannelConfig(cfg, pluginId) - ) { - enableState = { enabled: true }; - } + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: candidate.origin, + config: normalized, + rootConfig: cfg, + }); const entry = normalized.entries[pluginId]; const record = createPluginRecord({ id: pluginId, diff --git a/src/plugins/toggle-config.ts b/src/plugins/toggle-config.ts new file mode 100644 index 00000000000..cfabbeb2874 --- /dev/null +++ b/src/plugins/toggle-config.ts @@ -0,0 +1,47 @@ +import { normalizeChatChannelId } from "../channels/registry.js"; +import type { OpenClawConfig } from "../config/config.js"; + +export function setPluginEnabledInConfig( + config: OpenClawConfig, + pluginId: string, + enabled: boolean, +): OpenClawConfig { + const builtInChannelId = normalizeChatChannelId(pluginId); + const resolvedId = builtInChannelId ?? pluginId; + + const next: OpenClawConfig = { + ...config, + plugins: { + ...config.plugins, + entries: { + ...config.plugins?.entries, + [resolvedId]: { + ...(config.plugins?.entries?.[resolvedId] as object | undefined), + enabled, + }, + }, + }, + }; + + if (!builtInChannelId) { + return next; + } + + const channels = config.channels as Record | undefined; + const existing = channels?.[builtInChannelId]; + const existingRecord = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record) + : {}; + + return { + ...next, + channels: { + ...config.channels, + [builtInChannelId]: { + ...existingRecord, + enabled, + }, + }, + }; +}