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
This commit is contained in:
Ayane 2026-03-06 10:51:22 +08:00 committed by Tak Hoffman
parent adec8b28bb
commit dd64501262
2 changed files with 74 additions and 2 deletions

View File

@ -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", () => {

View File

@ -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 });
}