Skip to content

Commit 788ea6e

Browse files
shadril238steipete
authored andcommitted
fix: suppress false duplicate plugin id warning for symlinked extensions
When the same plugin directory is discovered through different path representations (e.g. symlinks), the manifest registry incorrectly warns about a duplicate plugin id. This is a false positive that appears for bundled extensions like feishu (#16208). Compare fs.realpathSync() of both candidates' rootDir before emitting the duplicate warning. If they resolve to the same physical directory, silently skip the duplicate instead of warning. Also change seenIds from Set<string> to Map<string, PluginCandidate> to track the first-seen candidate for comparison. Closes #16208
1 parent 1a7e180 commit 788ea6e

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { randomUUID } from "node:crypto";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import type { PluginCandidate } from "./discovery.js";
7+
import { loadPluginManifestRegistry } from "./manifest-registry.js";
8+
9+
const tempDirs: string[] = [];
10+
11+
function makeTempDir() {
12+
const dir = path.join(os.tmpdir(), `openclaw-manifest-registry-${randomUUID()}`);
13+
fs.mkdirSync(dir, { recursive: true });
14+
tempDirs.push(dir);
15+
return dir;
16+
}
17+
18+
function writeManifest(dir: string, manifest: Record<string, unknown>) {
19+
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), JSON.stringify(manifest), "utf-8");
20+
}
21+
22+
afterEach(() => {
23+
for (const dir of tempDirs.splice(0)) {
24+
try {
25+
fs.rmSync(dir, { recursive: true, force: true });
26+
} catch {
27+
// ignore cleanup failures
28+
}
29+
}
30+
});
31+
32+
describe("loadPluginManifestRegistry", () => {
33+
it("emits duplicate warning for truly distinct plugins with same id", () => {
34+
const dirA = makeTempDir();
35+
const dirB = makeTempDir();
36+
const manifest = { id: "test-plugin", configSchema: { type: "object" } };
37+
writeManifest(dirA, manifest);
38+
writeManifest(dirB, manifest);
39+
40+
const candidates: PluginCandidate[] = [
41+
{
42+
idHint: "test-plugin",
43+
source: path.join(dirA, "index.ts"),
44+
rootDir: dirA,
45+
origin: "bundled",
46+
},
47+
{
48+
idHint: "test-plugin",
49+
source: path.join(dirB, "index.ts"),
50+
rootDir: dirB,
51+
origin: "global",
52+
},
53+
];
54+
55+
const registry = loadPluginManifestRegistry({
56+
candidates,
57+
cache: false,
58+
});
59+
60+
const duplicateWarnings = registry.diagnostics.filter(
61+
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
62+
);
63+
expect(duplicateWarnings.length).toBe(1);
64+
});
65+
66+
it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => {
67+
const realDir = makeTempDir();
68+
const manifest = { id: "feishu", configSchema: { type: "object" } };
69+
writeManifest(realDir, manifest);
70+
71+
// Create a symlink pointing to the same directory
72+
const symlinkParent = makeTempDir();
73+
const symlinkPath = path.join(symlinkParent, "feishu-link");
74+
try {
75+
fs.symlinkSync(realDir, symlinkPath, "junction");
76+
} catch {
77+
// On systems where symlinks are not supported (e.g. restricted Windows),
78+
// skip this test gracefully.
79+
return;
80+
}
81+
82+
const candidates: PluginCandidate[] = [
83+
{
84+
idHint: "feishu",
85+
source: path.join(realDir, "index.ts"),
86+
rootDir: realDir,
87+
origin: "bundled",
88+
},
89+
{
90+
idHint: "feishu",
91+
source: path.join(symlinkPath, "index.ts"),
92+
rootDir: symlinkPath,
93+
origin: "bundled",
94+
},
95+
];
96+
97+
const registry = loadPluginManifestRegistry({
98+
candidates,
99+
cache: false,
100+
});
101+
102+
const duplicateWarnings = registry.diagnostics.filter(
103+
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
104+
);
105+
expect(duplicateWarnings.length).toBe(0);
106+
});
107+
108+
it("suppresses duplicate warning when candidates have identical rootDir paths", () => {
109+
const dir = makeTempDir();
110+
const manifest = { id: "same-path-plugin", configSchema: { type: "object" } };
111+
writeManifest(dir, manifest);
112+
113+
const candidates: PluginCandidate[] = [
114+
{
115+
idHint: "same-path-plugin",
116+
source: path.join(dir, "a.ts"),
117+
rootDir: dir,
118+
origin: "bundled",
119+
},
120+
{
121+
idHint: "same-path-plugin",
122+
source: path.join(dir, "b.ts"),
123+
rootDir: dir,
124+
origin: "global",
125+
},
126+
];
127+
128+
const registry = loadPluginManifestRegistry({
129+
candidates,
130+
cache: false,
131+
});
132+
133+
const duplicateWarnings = registry.diagnostics.filter(
134+
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
135+
);
136+
expect(duplicateWarnings.length).toBe(0);
137+
});
138+
});

src/plugins/manifest-registry.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export function loadPluginManifestRegistry(params: {
138138
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
139139
const candidates: PluginCandidate[] = discovery.candidates;
140140
const records: PluginManifestRecord[] = [];
141-
const seenIds = new Set<string>();
141+
const seenIds = new Map<string, PluginCandidate>();
142142

143143
for (const candidate of candidates) {
144144
const manifestRes = loadPluginManifest(candidate.rootDir);
@@ -161,15 +161,29 @@ export function loadPluginManifestRegistry(params: {
161161
});
162162
}
163163

164-
if (seenIds.has(manifest.id)) {
164+
const existingCandidate = seenIds.get(manifest.id);
165+
if (existingCandidate) {
166+
// Check whether both candidates point to the same physical directory
167+
// (e.g. via symlinks or different path representations). If so, this
168+
// is a false-positive duplicate and can be silently skipped.
169+
let samePlugin = false;
170+
try {
171+
samePlugin =
172+
fs.realpathSync(existingCandidate.rootDir) === fs.realpathSync(candidate.rootDir);
173+
} catch {
174+
// If either path is inaccessible, fall through to duplicate warning
175+
}
176+
if (samePlugin) {
177+
continue;
178+
}
165179
diagnostics.push({
166180
level: "warn",
167181
pluginId: manifest.id,
168182
source: candidate.source,
169183
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
170184
});
171185
} else {
172-
seenIds.add(manifest.id);
186+
seenIds.set(manifest.id, candidate);
173187
}
174188

175189
const configSchema = manifest.configSchema;

0 commit comments

Comments
 (0)