diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa9732f242..d070b3395f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ Docs: https://docs.openclaw.ai - Ollama/thinking off: route `thinkingLevel=off` through the live Ollama extension request path so thinking-capable Ollama models now receive top-level `think: false` instead of silently generating hidden reasoning tokens. (#53200) Thanks @BruceMacD. - Plugins/diffs: stage bundled `@pierre/diffs` runtime dependencies during packaged updates so the bundled diff viewer keeps loading after global installs and updates. (#56077) Thanks @gumadeiras. - Plugins/diffs: load bundled Pierre themes without JSON module imports so diff rendering keeps working on newer Node builds. (#45869) thanks @NickHood1984. +- Plugins/uninstall: remove owned `channels.` config when uninstalling channel plugins, and keep the uninstall preview aligned with explicit channel ownership so built-in channels and shared keys stay intact. (#35915) Thanks @wbxl2000. ## 2026.3.24 diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 113c9928a1d..b446eddd5a4 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -17,7 +17,11 @@ import { buildPluginStatusReport, formatPluginCompatibilityNotice, } from "../plugins/status.js"; -import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; +import { + resolveUninstallChannelConfigKeys, + resolveUninstallDirectoryTarget, + uninstallPlugin, +} from "../plugins/uninstall.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; @@ -626,6 +630,15 @@ export function registerPluginsCli(program: Command) { if (cfg.plugins?.slots?.memory === pluginId) { preview.push(`memory slot (will reset to "memory-core")`); } + const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; + const channels = cfg.channels as Record | undefined; + if (hasInstall && channels) { + for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) { + if (Object.hasOwn(channels, key)) { + preview.push(`channel config (channels.${key})`); + } + } + } const deleteTarget = !keepFiles ? resolveUninstallDirectoryTarget({ pluginId, @@ -660,6 +673,7 @@ export function registerPluginsCli(program: Command) { const result = await uninstallPlugin({ config: cfg, pluginId, + channelIds, deleteFiles: !keepFiles, extensionsDir, }); @@ -690,6 +704,9 @@ export function registerPluginsCli(program: Command) { if (result.actions.memorySlot) { removed.push("memory slot"); } + if (result.actions.channelConfig) { + removed.push("channel config"); + } if (result.actions.directory) { removed.push("directory"); } diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 9286726afc2..7aaa759ad35 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginInstallDir } from "./install.js"; import { removePluginFromConfig, + resolveUninstallChannelConfigKeys, resolveUninstallDirectoryTarget, uninstallPlugin, } from "./uninstall.js"; @@ -101,6 +102,24 @@ async function createPluginDirFixture(baseDir: string, pluginId = "my-plugin") { return pluginDir; } +describe("resolveUninstallChannelConfigKeys", () => { + it("falls back to pluginId when channelIds are unknown", () => { + expect(resolveUninstallChannelConfigKeys("timbot")).toEqual(["timbot"]); + }); + + it("keeps explicit empty channelIds as remove-nothing", () => { + expect(resolveUninstallChannelConfigKeys("telegram", { channelIds: [] })).toEqual([]); + }); + + it("filters shared keys and duplicate channel ids", () => { + expect( + resolveUninstallChannelConfigKeys("bad-plugin", { + channelIds: ["defaults", "discord", "discord", "modelByChannel", "slack"], + }), + ).toEqual(["discord", "slack"]); + }); +}); + describe("removePluginFromConfig", () => { it("removes plugin from entries", () => { const config: OpenClawConfig = { @@ -308,6 +327,211 @@ describe("removePluginFromConfig", () => { expect(result.plugins?.enabled).toBe(true); expect(result.plugins?.deny).toEqual(["denied-plugin"]); }); + + it("removes channel config for installed extension plugin", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + timbot: { enabled: true }, + }, + installs: { + timbot: { source: "npm", spec: "timbot@1.0.0" }, + }, + }, + channels: { + timbot: { sdkAppId: "123", secretKey: "abc" }, + telegram: { enabled: true }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "timbot"); + + expect((result.channels as Record)?.timbot).toBeUndefined(); + expect((result.channels as Record)?.telegram).toEqual({ enabled: true }); + expect(actions.channelConfig).toBe(true); + }); + + it("does not remove channel config for built-in channel without install record", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + telegram: { enabled: true }, + }, + }, + channels: { + telegram: { enabled: true }, + discord: { enabled: true }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "telegram"); + + // Built-in channels have no install record, so channel config must be preserved. + expect((result.channels as Record)?.telegram).toEqual({ enabled: true }); + expect(actions.channelConfig).toBe(false); + }); + + it("cleans up channels object when removing the only channel config", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + timbot: { enabled: true }, + }, + installs: { + timbot: { source: "npm", spec: "timbot@1.0.0" }, + }, + }, + channels: { + timbot: { sdkAppId: "123" }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "timbot"); + + expect(result.channels).toBeUndefined(); + expect(actions.channelConfig).toBe(true); + }); + + it("does not set channelConfig action when no channel config exists", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + installs: { + "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, + }, + }, + }; + + const { actions } = removePluginFromConfig(config, "my-plugin"); + + expect(actions.channelConfig).toBe(false); + }); + + it("does not remove channel config when plugin has no install record", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + discord: { enabled: true }, + }, + }, + channels: { + discord: { enabled: true, token: "abc" }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "discord"); + + // No install record means this is a built-in channel; config must stay. + expect((result.channels as Record)?.discord).toEqual({ + enabled: true, + token: "abc", + }); + expect(actions.channelConfig).toBe(false); + }); + + it("removes channel config using explicit channelIds when pluginId differs", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "timbot-plugin": { enabled: true }, + }, + installs: { + "timbot-plugin": { source: "npm", spec: "timbot-plugin@1.0.0" }, + }, + }, + channels: { + timbot: { sdkAppId: "123" }, + "timbot-v2": { sdkAppId: "456" }, + telegram: { enabled: true }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "timbot-plugin", { + channelIds: ["timbot", "timbot-v2"], + }); + + const ch = result.channels as Record | undefined; + expect(ch?.timbot).toBeUndefined(); + expect(ch?.["timbot-v2"]).toBeUndefined(); + expect(ch?.telegram).toEqual({ enabled: true }); + expect(actions.channelConfig).toBe(true); + }); + + it("preserves shared channel keys (defaults, modelByChannel)", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + timbot: { enabled: true }, + }, + installs: { + timbot: { source: "npm", spec: "timbot@1.0.0" }, + }, + }, + channels: { + defaults: { groupPolicy: "opt-in" }, + modelByChannel: { timbot: "gpt-3.5" } as Record, + timbot: { sdkAppId: "123" }, + } as unknown as OpenClawConfig["channels"], + }; + + const { config: result, actions } = removePluginFromConfig(config, "timbot"); + + const ch = result.channels as Record | undefined; + expect(ch?.timbot).toBeUndefined(); + expect(ch?.defaults).toEqual({ groupPolicy: "opt-in" }); + expect(ch?.modelByChannel).toEqual({ timbot: "gpt-3.5" }); + expect(actions.channelConfig).toBe(true); + }); + + it("does not remove shared keys even when passed as channelIds", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "bad-plugin": { enabled: true }, + }, + installs: { + "bad-plugin": { source: "npm", spec: "bad-plugin@1.0.0" }, + }, + }, + channels: { + defaults: { groupPolicy: "opt-in" }, + } as unknown as OpenClawConfig["channels"], + }; + + const { config: result, actions } = removePluginFromConfig(config, "bad-plugin", { + channelIds: ["defaults"], + }); + + const ch = result.channels as Record | undefined; + expect(ch?.defaults).toEqual({ groupPolicy: "opt-in" }); + expect(actions.channelConfig).toBe(false); + }); + + it("skips channel cleanup when channelIds is empty array (non-channel plugin)", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + telegram: { enabled: true }, + }, + installs: { + telegram: { source: "npm", spec: "telegram@1.0.0" }, + }, + }, + channels: { + telegram: { enabled: true }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "telegram", { + channelIds: [], + }); + + // Empty channelIds means the plugin declares no channels, so channel config must stay. + expect((result.channels as Record)?.telegram).toEqual({ enabled: true }); + expect(actions.channelConfig).toBe(false); + }); }); describe("uninstallPlugin", () => { diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 40fe5b90a59..59aadfb5956 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -11,6 +11,7 @@ export type UninstallActions = { allowlist: boolean; loadPath: boolean; memorySlot: boolean; + channelConfig: boolean; directory: boolean; }; @@ -58,13 +59,39 @@ export function resolveUninstallDirectoryTarget(params: { return defaultPath; } +const SHARED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + +/** + * Resolve the channel config keys owned by a plugin during uninstall. + * - `channelIds === undefined`: fall back to the plugin id for backward compatibility. + * - `channelIds === []`: explicit "owns no channels" signal; remove nothing. + */ +export function resolveUninstallChannelConfigKeys( + pluginId: string, + opts?: { channelIds?: string[] }, +): string[] { + const rawKeys = opts?.channelIds ?? [pluginId]; + const seen = new Set(); + const keys: string[] = []; + for (const key of rawKeys) { + if (SHARED_CHANNEL_CONFIG_KEYS.has(key) || seen.has(key)) { + continue; + } + seen.add(key); + keys.push(key); + } + return keys; +} + /** * Remove plugin references from config (pure config mutation). - * Returns a new config with the plugin removed from entries, installs, allow, load.paths, and slots. + * Returns a new config with the plugin removed from entries, installs, allow, load.paths, slots, + * and owned channel config. */ export function removePluginFromConfig( cfg: OpenClawConfig, pluginId: string, + opts?: { channelIds?: string[] }, ): { config: OpenClawConfig; actions: Omit } { const actions: Omit = { entry: false, @@ -72,6 +99,7 @@ export function removePluginFromConfig( allowlist: false, loadPath: false, memorySlot: false, + channelConfig: false, }; const pluginsConfig = cfg.plugins ?? {}; @@ -155,9 +183,28 @@ export function removePluginFromConfig( delete cleanedPlugins.slots; } + // Remove channel config owned by this installed plugin. + // Built-in channels have no install record, so keep their config untouched. + const hasInstallRecord = Object.hasOwn(cfg.plugins?.installs ?? {}, pluginId); + let channels = cfg.channels as Record | undefined; + if (hasInstallRecord && channels) { + for (const key of resolveUninstallChannelConfigKeys(pluginId, opts)) { + if (!Object.hasOwn(channels, key)) { + continue; + } + const { [key]: _removed, ...rest } = channels; + channels = Object.keys(rest).length > 0 ? rest : undefined; + actions.channelConfig = true; + if (!channels) { + break; + } + } + } + const config: OpenClawConfig = { ...cfg, plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined, + channels: channels as OpenClawConfig["channels"], }; return { config, actions }; @@ -166,6 +213,7 @@ export function removePluginFromConfig( export type UninstallPluginParams = { config: OpenClawConfig; pluginId: string; + channelIds?: string[]; deleteFiles?: boolean; extensionsDir?: string; }; @@ -177,7 +225,7 @@ export type UninstallPluginParams = { export async function uninstallPlugin( params: UninstallPluginParams, ): Promise { - const { config, pluginId, deleteFiles = true, extensionsDir } = params; + const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params; // Validate plugin exists const hasEntry = pluginId in (config.plugins?.entries ?? {}); @@ -191,7 +239,9 @@ export async function uninstallPlugin( const isLinked = installRecord?.source === "path"; // Remove from config - const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId); + const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId, { + channelIds, + }); const actions: UninstallActions = { ...configActions,