fix: prevent duplicate channel plugin tools

This commit is contained in:
Peter Steinberger
2026-04-26 01:05:39 +01:00
parent 6ed642a86d
commit 3a4325b285
8 changed files with 457 additions and 23 deletions

View File

@@ -65,6 +65,9 @@ Docs: https://docs.openclaw.ai
- Feishu: keep synthetic card-action and bot-menu ids out of platform reply
targets, using the real card callback message id when Feishu provides one and
plain-sending otherwise. Fixes #71673. Thanks @eddy1068.
- Plugins/QQ Bot: prefer an installed QQ Bot plugin that declares it replaces
the bundled `qqbot` channel, preventing duplicate `qqbot_channel_api` and
`qqbot_remind` tool registration noise. Fixes #63102.
- QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs
directly for owner-authorized senders instead of returning `cronParams` and
relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937)

View File

@@ -105,6 +105,10 @@ Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group
mentions, channel messages, and rich media including voice, images, videos,
and files.
Current OpenClaw releases bundle QQ Bot. Use the bundled setup in
[QQ Bot](/channels/qqbot) for normal installs; install this external plugin only
when you intentionally want the Tencent-maintained standalone package.
- **npm:** `@tencent-connect/openclaw-qqbot`
- **repo:** [github.com/tencent-connect/openclaw-qqbot](https://github.com/tencent-connect/openclaw-qqbot)

View File

@@ -508,6 +508,11 @@ runtime loads. Read-only channel setup/status discovery can use this metadata
directly for configured external channels when no setup entry is available, or
when `setup.requiresRuntime: false` declares setup runtime unnecessary.
`channelConfigs` is plugin manifest metadata, not a new top-level user config
section. Users still configure channel instances under `channels.<channel-id>`.
OpenClaw reads manifest metadata to decide which plugin owns that configured
channel before plugin runtime code executes.
For a channel plugin, `configSchema` and `channelConfigs` describe different
paths:
@@ -554,6 +559,43 @@ Each channel entry can include:
| `description` | `string` | Short channel description for inspect and catalog surfaces. |
| `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. |
### Replacing another channel plugin
Use `preferOver` when your plugin is the preferred owner for a channel id that
another plugin can also provide. Common cases are a renamed plugin id, a
standalone plugin that supersedes a bundled plugin, or a maintained fork that
keeps the same channel id for config compatibility.
```json
{
"id": "acme-chat",
"channels": ["chat"],
"channelConfigs": {
"chat": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webhookUrl": { "type": "string" }
}
},
"preferOver": ["chat"]
}
}
}
```
When `channels.chat` is configured, OpenClaw considers both the channel id and
the preferred plugin id. If the lower-priority plugin was only selected because
it is bundled or enabled by default, OpenClaw disables it in the effective
runtime config so one plugin owns the channel and its tools. Explicit user
selection still wins: if the user explicitly enables both plugins, OpenClaw
preserves that choice and reports duplicate channel/tool diagnostics instead of
silently changing the requested plugin set.
Keep `preferOver` scoped to plugin ids that can really provide the same channel.
It is not a general priority field and it does not rename user config keys.
## modelSupport reference
Use `modelSupport` when OpenClaw should infer your provider plugin from

View File

@@ -232,6 +232,40 @@ do not run in live chat traffic, check these first:
Gateway session/status surfaces and, when debugging provider payloads, start
the Gateway with `--raw-stream --raw-stream-path <path>`.
### Duplicate channel or tool ownership
Symptoms:
- `channel already registered: <channel-id> (<plugin-id>)`
- `channel setup already registered: <channel-id> (<plugin-id>)`
- `plugin tool name conflict (<plugin-id>): <tool-name>`
These mean more than one enabled plugin is trying to own the same channel,
setup flow, or tool name. The most common cause is an external channel plugin
installed beside a bundled plugin that now provides the same channel id.
Debug steps:
- Run `openclaw plugins list --enabled --verbose` to see every enabled plugin
and origin.
- Run `openclaw plugins inspect <id> --json` for each suspected plugin and
compare `channels`, `channelConfigs`, `tools`, and diagnostics.
- Run `openclaw plugins registry --refresh` after installing or removing
plugin packages so persisted metadata reflects the current install.
- Restart the Gateway after install, registry, or config changes.
Fix options:
- If one plugin intentionally replaces another for the same channel id, the
preferred plugin should declare `channelConfigs.<channel-id>.preferOver` with
the lower-priority plugin id. See [/plugins/manifest#replacing-another-channel-plugin](/plugins/manifest#replacing-another-channel-plugin).
- If the duplicate is accidental, disable one side with
`plugins.entries.<plugin-id>.enabled: false` or remove the stale plugin
install.
- If you explicitly enabled both plugins, OpenClaw keeps that request and
reports the conflict. Pick one owner for the channel or rename plugin-owned
tools so the runtime surface is unambiguous.
## Plugin slots (exclusive categories)
Some categories are exclusive (only one active at a time):

View File

@@ -207,6 +207,90 @@ describe("applyPluginAutoEnable channels", () => {
expect(result.changes).toEqual([]);
});
it("prefers an external plugin that declares preferOver for a bundled channel", () => {
const result = applyPluginAutoEnable({
config: {
channels: { qqbot: { appId: "app", clientSecret: "secret" } },
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{ id: "qqbot", channels: ["qqbot"] },
{
id: "openclaw-qqbot",
channels: ["qqbot"],
channelConfigs: {
qqbot: {
schema: { type: "object" },
preferOver: ["qqbot"],
},
},
},
]),
});
expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.qqbot?.enabled).toBe(false);
expect(result.changes.join("\n")).toContain("QQ Bot configured, enabled automatically.");
});
it("falls back to the bundled channel when the preferred external plugin is disabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { qqbot: { appId: "app", clientSecret: "secret" } },
plugins: { entries: { "openclaw-qqbot": { enabled: false } } },
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{ id: "qqbot", channels: ["qqbot"] },
{
id: "openclaw-qqbot",
channels: ["qqbot"],
channelConfigs: {
qqbot: {
schema: { type: "object" },
preferOver: ["qqbot"],
},
},
},
]),
});
expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(false);
expect(result.config.plugins?.entries?.qqbot).toBeUndefined();
expect(result.config.channels?.qqbot?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("QQ Bot configured, enabled automatically.");
});
it("does not auto-disable a lower-priority channel plugin that was explicitly selected", () => {
const result = applyPluginAutoEnable({
config: {
channels: { qqbot: { appId: "app", clientSecret: "secret" } },
plugins: {
entries: {
qqbot: { enabled: true },
},
},
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{ id: "qqbot", channels: ["qqbot"] },
{
id: "openclaw-qqbot",
channels: ["qqbot"],
channelConfigs: {
qqbot: {
schema: { type: "object" },
preferOver: ["qqbot"],
},
},
},
]),
});
expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.qqbot?.enabled).toBe(true);
});
it("falls back to channel key as plugin id when no installed manifest declares the channel", () => {
const result = applyPluginAutoEnable({
config: {
@@ -246,7 +330,7 @@ describe("applyPluginAutoEnable channels", () => {
});
expect(result.config.plugins?.entries?.primary?.enabled).toBe(true);
expect(result.config.plugins?.entries?.secondary?.enabled).toBeUndefined();
expect(result.config.plugins?.entries?.secondary?.enabled).toBe(false);
expect(result.changes.join("\n")).toContain("primary configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"secondary configured, enabled automatically.",
@@ -257,7 +341,7 @@ describe("applyPluginAutoEnable channels", () => {
const result = applyWithBluebubblesImessageConfig();
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false);
expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"iMessage configured, enabled automatically.",

View File

@@ -219,27 +219,60 @@ function resolvePluginIdForConfiguredWebFetchProvider(
});
}
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
const map = new Map<string, string>();
function normalizeManifestChannelId(channelId: string): string {
return normalizeChatChannelId(channelId) ?? channelId;
}
function getManifestChannelPreferOver(
plugin: PluginManifestRecord,
channelId: string,
): readonly string[] {
return plugin.channelConfigs?.[channelId]?.preferOver ?? [];
}
function collectPluginIdsForConfiguredChannel(
channelId: string,
registry: PluginManifestRegistry,
): string[] {
const normalizedChannelId = normalizeManifestChannelId(channelId);
const builtInId = normalizeChatChannelId(normalizedChannelId);
const claims: Array<{ plugin: PluginManifestRecord; preferOver: readonly string[] }> = [];
for (const record of registry.plugins) {
for (const channelId of record.channels ?? []) {
if (channelId && !map.has(channelId)) {
map.set(channelId, record.id);
if (
(record.channels ?? []).some((id) => normalizeManifestChannelId(id) === normalizedChannelId)
) {
claims.push({
plugin: record,
preferOver: getManifestChannelPreferOver(record, normalizedChannelId),
});
}
}
if (claims.length === 0) {
return [builtInId ?? normalizedChannelId];
}
const claimIds = new Set(claims.map((claim) => claim.plugin.id));
if (builtInId) {
claimIds.add(builtInId);
}
const preferredIds = new Set<string>();
for (const claim of claims) {
for (const preferredOverId of claim.preferOver) {
if (claimIds.has(preferredOverId)) {
// Keep both sides as candidates. The preferOver filter later disables
// the lower-priority plugin unless the preferred plugin is explicitly
// disabled/denied, preserving fallback to bundled channel support.
preferredIds.add(claim.plugin.id);
preferredIds.add(preferredOverId);
}
}
}
return map;
}
function resolvePluginIdForChannel(
channelId: string,
channelToPluginId: ReadonlyMap<string, string>,
): string {
const builtInId = normalizeChatChannelId(channelId);
if (builtInId) {
return builtInId;
if (preferredIds.size > 0) {
return [...preferredIds].toSorted((left, right) => left.localeCompare(right));
}
return channelToPluginId.get(channelId) ?? channelId;
return [builtInId ?? claims[0]?.plugin.id ?? normalizedChannelId];
}
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
@@ -389,9 +422,7 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig, env: NodeJS.Pr
if (key === "defaults" || key === "modelByChannel") {
continue;
}
if (!normalizeChatChannelId(key)) {
return true;
}
return true;
}
return false;
}
@@ -459,11 +490,11 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: {
registry: PluginManifestRegistry;
}): PluginAutoEnableCandidate[] {
const changes: PluginAutoEnableCandidate[] = [];
const channelToPluginId = buildChannelToPluginIdMap(params.registry);
for (const channelId of collectCandidateChannelIds(params.config, params.env)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(params.config, channelId, params.env)) {
changes.push({ pluginId, kind: "channel-configured", channelId });
for (const pluginId of collectPluginIdsForConfiguredChannel(channelId, params.registry)) {
changes.push({ pluginId, kind: "channel-configured", channelId });
}
}
}
@@ -582,6 +613,45 @@ function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
return Array.isArray(deny) && deny.includes(pluginId);
}
function isPluginExplicitlySelected(cfg: OpenClawConfig, pluginId: string): boolean {
const allow = cfg.plugins?.allow;
if (Array.isArray(allow) && allow.includes(pluginId)) {
return true;
}
return hasMaterialPluginEntryConfig(cfg.plugins?.entries?.[pluginId]);
}
function disableImplicitPreferredOverPlugin(params: {
config: OpenClawConfig;
originalConfig: OpenClawConfig;
pluginId: string;
manifestRegistry: PluginManifestRegistry;
}): OpenClawConfig {
if (isPluginExplicitlySelected(params.originalConfig, params.pluginId)) {
return params.config;
}
if (
!normalizeChatChannelId(params.pluginId) &&
!isKnownPluginId(params.pluginId, params.manifestRegistry)
) {
return params.config;
}
const existingEntry = params.config.plugins?.entries?.[params.pluginId];
return {
...params.config,
plugins: {
...params.config.plugins,
entries: {
...params.config.plugins?.entries,
[params.pluginId]: {
...(existingEntry && typeof existingEntry === "object" ? existingEntry : {}),
enabled: false,
},
},
},
};
}
function isBuiltInChannelAlreadyEnabled(cfg: OpenClawConfig, channelId: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[channelId];
@@ -753,6 +823,12 @@ export function materializePluginAutoEnableCandidatesInternal(params: {
preferOverCache,
})
) {
next = disableImplicitPreferredOverPlugin({
config: next,
originalConfig: params.config ?? {},
pluginId: entry.pluginId,
manifestRegistry: params.manifestRegistry,
});
continue;
}

View File

@@ -0,0 +1,185 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { clearPluginDiscoveryCache } from "./discovery.js";
import { clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js";
import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
import { resetPluginRuntimeStateForTest } from "./runtime.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-prefer-over-"));
if (process.platform !== "win32") {
fs.chmodSync(dir, 0o755);
}
tempDirs.push(dir);
return dir;
}
function writeChannelToolPlugin(params: {
rootDir: string;
id: string;
channelId: string;
enabledByDefault?: boolean;
preferOver?: string[];
}): string {
const pluginDir = path.join(params.rootDir, params.id);
fs.mkdirSync(pluginDir, { recursive: true });
if (process.platform !== "win32") {
fs.chmodSync(pluginDir, 0o755);
}
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: params.id,
channels: [params.channelId],
...(params.enabledByDefault ? { enabledByDefault: true } : {}),
channelConfigs: {
[params.channelId]: {
schema: { type: "object" },
...(params.preferOver ? { preferOver: params.preferOver } : {}),
},
},
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`module.exports = {
id: ${JSON.stringify(params.id)},
register(api) {
api.registerChannel({
plugin: {
id: ${JSON.stringify(params.channelId)},
meta: {
id: ${JSON.stringify(params.channelId)},
label: ${JSON.stringify(params.channelId)},
selectionLabel: ${JSON.stringify(params.channelId)},
docsPath: ${JSON.stringify(`/channels/${params.channelId}`)},
blurb: "fixture channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
api.registerTool({
name: "qqbot_remind",
description: "fixture",
parameters: { type: "object", properties: {} },
execute() { return { content: [{ type: "text", text: "ok" }] }; },
}, { name: "qqbot_remind" });
},
};`,
"utf-8",
);
return pluginDir;
}
afterEach(() => {
clearPluginLoaderCache();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
resetPluginRuntimeStateForTest();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin loader preferOver activation", () => {
it("loads the preferred external channel plugin without the replaced bundled plugin tools", () => {
const bundledRoot = makeTempDir();
writeChannelToolPlugin({
rootDir: bundledRoot,
id: "qqbot",
channelId: "qqbot",
enabledByDefault: true,
});
const externalRoot = makeTempDir();
const externalPluginDir = writeChannelToolPlugin({
rootDir: externalRoot,
id: "openclaw-qqbot",
channelId: "qqbot",
preferOver: ["qqbot"],
});
const env = {
OPENCLAW_STATE_DIR: makeTempDir(),
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
};
const rawConfig = {
channels: { qqbot: { appId: "app", clientSecret: "secret" } },
plugins: { load: { paths: [externalPluginDir] } },
};
const autoEnabled = applyPluginAutoEnable({ config: rawConfig, env });
const registry = loadOpenClawPlugins({
cache: false,
config: autoEnabled.config,
activationSourceConfig: rawConfig,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
env,
});
expect(autoEnabled.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true);
expect(autoEnabled.config.plugins?.entries?.qqbot?.enabled).toBe(false);
expect(registry.plugins.find((plugin) => plugin.id === "openclaw-qqbot")?.status).toBe(
"loaded",
);
expect(registry.plugins.find((plugin) => plugin.id === "qqbot")?.status).toBe("disabled");
expect(registry.tools.map((tool) => tool.pluginId)).toEqual(["openclaw-qqbot"]);
expect(registry.diagnostics.map((diag) => diag.message).join("\n")).not.toContain(
"plugin tool name conflict",
);
});
it("blocks tools from a plugin that loses a duplicate channel registration", () => {
const bundledRoot = makeTempDir();
writeChannelToolPlugin({
rootDir: bundledRoot,
id: "qqbot",
channelId: "qqbot",
enabledByDefault: true,
});
const externalRoot = makeTempDir();
const externalPluginDir = writeChannelToolPlugin({
rootDir: externalRoot,
id: "openclaw-qqbot",
channelId: "qqbot",
});
const env = {
OPENCLAW_STATE_DIR: makeTempDir(),
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
};
const registry = loadOpenClawPlugins({
cache: false,
config: {
channels: { qqbot: { appId: "app", clientSecret: "secret" } },
plugins: {
load: { paths: [externalPluginDir] },
entries: {
qqbot: { enabled: true },
"openclaw-qqbot": { enabled: true },
},
},
},
env,
});
const diagnostics = registry.diagnostics.map((diag) => diag.message).join("\n");
expect(diagnostics).toContain("channel already registered: qqbot");
expect(diagnostics).not.toContain("plugin tool name conflict");
expect(registry.tools.map((tool) => tool.pluginId)).toHaveLength(1);
});
});

View File

@@ -226,6 +226,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
const pluginHookRollback = new Map<string, HookRollbackEntry[]>();
const pluginsWithChannelRegistrationConflict = new Set<string>();
const pushDiagnostic = (diag: PluginDiagnostic) => {
registry.diagnostics.push(diag);
@@ -373,6 +374,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
tool: AnyAgentTool | OpenClawPluginToolFactory,
opts?: { name?: string; names?: string[]; optional?: boolean },
) => {
if (pluginsWithChannelRegistrationConflict.has(record.id)) {
return;
}
const names = opts?.names ?? (opts?.name ? [opts.name] : []);
const optional = opts?.optional === true;
const factory: OpenClawPluginToolFactory =
@@ -674,6 +678,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
source: record.source,
message: `channel already registered: ${id} (${existingRuntime.pluginId})`,
});
pluginsWithChannelRegistrationConflict.add(record.id);
return;
}
const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id);
@@ -692,6 +697,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
source: record.source,
message: `channel setup already registered: ${id} (${existingSetup.pluginId})`,
});
pluginsWithChannelRegistrationConflict.add(record.id);
return;
}
record.channelIds.push(id);