fix: suppress false duplicate plugin warnings (#16222) (thanks @shadril238) (#16245)

This commit is contained in:
Peter Steinberger
2026-02-14 15:45:21 +01:00
committed by GitHub
parent 4c7838e3cf
commit 4f043991e0
3 changed files with 85 additions and 12 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058)
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.

View File

@@ -20,7 +20,11 @@ function writeManifest(dir: string, manifest: Record<string, unknown>) {
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
break;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
@@ -135,4 +139,41 @@ describe("loadPluginManifestRegistry", () => {
);
expect(duplicateWarnings.length).toBe(0);
});
it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => {
const dir = makeTempDir();
fs.mkdirSync(path.join(dir, "sub"), { recursive: true });
const manifest = { id: "precedence-plugin", configSchema: { type: "object" } };
writeManifest(dir, manifest);
// Use a different-but-equivalent path representation without requiring symlinks.
const altDir = path.join(dir, "sub", "..");
const candidates: PluginCandidate[] = [
{
idHint: "precedence-plugin",
source: path.join(dir, "index.ts"),
rootDir: dir,
origin: "bundled",
},
{
idHint: "precedence-plugin",
source: path.join(altDir, "index.ts"),
rootDir: altDir,
origin: "config",
},
];
const registry = loadPluginManifestRegistry({
candidates,
cache: false,
});
const duplicateWarnings = registry.diagnostics.filter(
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
);
expect(duplicateWarnings.length).toBe(0);
expect(registry.plugins.length).toBe(1);
expect(registry.plugins[0]?.origin).toBe("config");
});
});

View File

@@ -6,6 +6,25 @@ import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-s
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
type SeenIdEntry = {
candidate: PluginCandidate;
recordIndex: number;
};
function pluginOriginRank(origin: PluginOrigin): number {
// Precedence: config > workspace > global > bundled
switch (origin) {
case "config":
return 0;
case "workspace":
return 1;
case "global":
return 2;
case "bundled":
return 3;
}
}
export type PluginManifestRecord = {
id: string;
name?: string;
@@ -138,7 +157,7 @@ export function loadPluginManifestRegistry(params: {
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
const candidates: PluginCandidate[] = discovery.candidates;
const records: PluginManifestRecord[] = [];
const seenIds = new Map<string, PluginCandidate>();
const seenIds = new Map<string, SeenIdEntry>();
for (const candidate of candidates) {
const manifestRes = loadPluginManifest(candidate.rootDir);
@@ -161,19 +180,37 @@ export function loadPluginManifestRegistry(params: {
});
}
const existingCandidate = seenIds.get(manifest.id);
if (existingCandidate) {
const configSchema = manifest.configSchema;
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
const schemaCacheKey = manifestMtime
? `${manifestRes.manifestPath}:${manifestMtime}`
: manifestRes.manifestPath;
const existing = seenIds.get(manifest.id);
if (existing) {
// Check whether both candidates point to the same physical directory
// (e.g. via symlinks or different path representations). If so, this
// is a false-positive duplicate and can be silently skipped.
let samePlugin = false;
try {
samePlugin =
fs.realpathSync(existingCandidate.rootDir) === fs.realpathSync(candidate.rootDir);
fs.realpathSync(existing.candidate.rootDir) === fs.realpathSync(candidate.rootDir);
} catch {
// If either path is inaccessible, fall through to duplicate warning
}
if (samePlugin) {
// Prefer higher-precedence origins even if candidates are passed in
// an unexpected order (config > workspace > global > bundled).
if (pluginOriginRank(candidate.origin) < pluginOriginRank(existing.candidate.origin)) {
records[existing.recordIndex] = buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
});
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
}
continue;
}
diagnostics.push({
@@ -183,15 +220,9 @@ export function loadPluginManifestRegistry(params: {
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
});
} else {
seenIds.set(manifest.id, candidate);
seenIds.set(manifest.id, { candidate, recordIndex: records.length });
}
const configSchema = manifest.configSchema;
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
const schemaCacheKey = manifestMtime
? `${manifestRes.manifestPath}:${manifestMtime}`
: manifestRes.manifestPath;
records.push(
buildRecord({
manifest,