mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
fix: prevent duplicate channel plugin tools
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
185
src/plugins/loader.prefer-over.test.ts
Normal file
185
src/plugins/loader.prefer-over.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user