Plugins: clean up channel config on uninstall (#35915)

* Plugins: clean up channel config on uninstall

`openclaw plugins uninstall` only removed `plugins.*` entries but left
`channels.<id>` config behind, causing errors when the gateway
referenced a channel whose plugin no longer existed.

Now `removePluginFromConfig` also deletes the matching
`channels.<pluginId>` entry (exact match only), and the CLI
previews/reports the removal. Shared config keys like `defaults`
and `modelByChannel` are guarded from accidental removal.

* Plugins: sync uninstall preview with channel cleanup

* fix: clean up channel config on uninstall (#35915) (thanks @wbxl2000)

---------

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
This commit is contained in:
qer 2026-03-28 08:28:38 +08:00 committed by GitHub
parent 87792c9050
commit 8c079a804c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 296 additions and 4 deletions

View File

@ -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.<id>` 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

View File

@ -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<string, unknown> | 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");
}

View File

@ -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<string, unknown>)?.timbot).toBeUndefined();
expect((result.channels as Record<string, unknown>)?.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<string, unknown>)?.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<string, unknown>)?.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<string, unknown> | 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<string, string>,
timbot: { sdkAppId: "123" },
} as unknown as OpenClawConfig["channels"],
};
const { config: result, actions } = removePluginFromConfig(config, "timbot");
const ch = result.channels as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>)?.telegram).toEqual({ enabled: true });
expect(actions.channelConfig).toBe(false);
});
});
describe("uninstallPlugin", () => {

View File

@ -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<string>();
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<UninstallActions, "directory"> } {
const actions: Omit<UninstallActions, "directory"> = {
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<string, unknown> | 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<UninstallPluginResult> {
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,