From dd64501262c08daa91989bd6aeb8237bd1adc449 Mon Sep 17 00:00:00 2001 From: Ayane Date: Fri, 6 Mar 2026 10:51:22 +0800 Subject: [PATCH] fix(plugins): deduplicate registry records for same-id plugins from different paths When the same plugin (e.g. feishu) is discovered from both the bundled extensions/ directory and the npm-installed ~/.openclaw/extensions/ directory, the manifest registry previously added both records. Having two records with the same plugin id caused duplicate channel registration and Gateway instability when modifying channel configuration. Apply the existing precedence logic (config > workspace > global > bundled) to genuine duplicates: keep the higher-precedence origin and skip the lower-precedence duplicate, just like the same-path dedup already does. The duplicate warning diagnostic is preserved so users are still informed. Closes #37028 --- src/plugins/manifest-registry.test.ts | 60 ++++++++++++++++++++++++++- src/plugins/manifest-registry.ts | 16 ++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 9212c6fcf05..7779870c00d 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -150,7 +150,65 @@ describe("loadPluginManifestRegistry", () => { }), ]; - expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(1); + const registry = loadRegistry(candidates); + expect(countDuplicateWarnings(registry)).toBe(1); + // Only one record should be kept to prevent Gateway instability + expect(registry.plugins.filter((p) => p.id === "test-plugin")).toHaveLength(1); + }); + + it("keeps higher-precedence origin when genuine duplicate is detected from different paths", () => { + const bundledDir = makeTempDir(); + const globalDir = makeTempDir(); + const manifest = { id: "feishu-dup", configSchema: { type: "object" } }; + writeManifest(bundledDir, manifest); + writeManifest(globalDir, manifest); + + // Bundled discovered first, then global (npm-installed copy) + const candidates: PluginCandidate[] = [ + createPluginCandidate({ + idHint: "feishu-dup", + rootDir: bundledDir, + origin: "bundled", + }), + createPluginCandidate({ + idHint: "feishu-dup", + rootDir: globalDir, + origin: "global", + }), + ]; + + const registry = loadRegistry(candidates); + expect(countDuplicateWarnings(registry)).toBe(1); + expect(registry.plugins.filter((p) => p.id === "feishu-dup")).toHaveLength(1); + // Global has higher precedence than bundled (config > workspace > global > bundled) + expect(registry.plugins[0]?.origin).toBe("global"); + }); + + it("keeps existing record when genuine duplicate has lower precedence", () => { + const configDir = makeTempDir(); + const globalDir = makeTempDir(); + const manifest = { id: "lower-prec", configSchema: { type: "object" } }; + writeManifest(configDir, manifest); + writeManifest(globalDir, manifest); + + const candidates: PluginCandidate[] = [ + createPluginCandidate({ + idHint: "lower-prec", + rootDir: configDir, + origin: "config", + }), + createPluginCandidate({ + idHint: "lower-prec", + rootDir: globalDir, + origin: "global", + }), + ]; + + const registry = loadRegistry(candidates); + expect(countDuplicateWarnings(registry)).toBe(1); + expect(registry.plugins.filter((p) => p.id === "lower-prec")).toHaveLength(1); + // Config has higher precedence, should be kept + expect(registry.plugins[0]?.origin).toBe("config"); }); it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index d392144f925..db91697c23a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -233,8 +233,22 @@ export function loadPluginManifestRegistry(params: { level: "warn", pluginId: manifest.id, source: candidate.source, - message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`, + message: `duplicate plugin id detected; skipping duplicate from ${candidate.source}`, }); + // Genuine duplicate from a different physical path: apply the same + // precedence logic as same-path duplicates to avoid registering two + // records with the same plugin id (which causes Gateway instability). + if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) { + records[existing.recordIndex] = buildRecord({ + manifest, + candidate, + manifestPath: manifestRes.manifestPath, + schemaCacheKey, + configSchema, + }); + seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); + } + continue; } else { seenIds.set(manifest.id, { candidate, recordIndex: records.length }); }