Compare commits

...

5 Commits

Author SHA1 Message Date
Tak Hoffman
a3d08bb3d2 style: align formatting after rebase 2026-03-08 22:33:19 -05:00
Tak Hoffman
8e486dffc9 fix(plugins): align duplicate warning with skipped copy 2026-03-08 22:29:39 -05:00
Ayane
bacebb1cf1 fix(plugins): use bundled-first precedence for genuine duplicate dedup
The manifest-registry dedup used PLUGIN_ORIGIN_RANK (global > bundled) for
genuine duplicates, but bundled plugins ship with the binary and should take
precedence over auto-discovered global (npm-installed) copies. This aligns
with the discovery order (bundled before global) and the loader's existing
expectations.

Add GENUINE_DUPLICATE_RANK (config > bundled > workspace > global) for
same-id/different-path duplicates while keeping the original
PLUGIN_ORIGIN_RANK for same-path dedup.
2026-03-08 22:29:39 -05:00
Ayane
c306c6e1fb ci: retrigger checks (previous run hit JS heap OOM) 2026-03-08 22:29:39 -05:00
Ayane
dd64501262 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
2026-03-08 22:29:39 -05:00
4 changed files with 160 additions and 23 deletions

View File

@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const probeGateway =
vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.fn(() => ({}));

View File

@ -1093,10 +1093,11 @@ describe("loadOpenClawPlugins", () => {
});
const entries = registry.plugins.filter((entry) => entry.id === "shadow");
const loaded = entries.find((entry) => entry.status === "loaded");
const overridden = entries.find((entry) => entry.status === "disabled");
expect(loaded?.origin).toBe("config");
expect(overridden?.origin).toBe("bundled");
// Manifest registry deduplicates genuine duplicates (same id, different paths),
// so only the higher-precedence record survives to the loader.
expect(entries).toHaveLength(1);
expect(entries[0]?.status).toBe("loaded");
expect(entries[0]?.origin).toBe("config");
});
it("prefers bundled plugin over auto-discovered global duplicate ids", () => {
@ -1133,11 +1134,11 @@ describe("loadOpenClawPlugins", () => {
});
const entries = registry.plugins.filter((entry) => entry.id === "feishu");
const loaded = entries.find((entry) => entry.status === "loaded");
const overridden = entries.find((entry) => entry.status === "disabled");
expect(loaded?.origin).toBe("bundled");
expect(overridden?.origin).toBe("global");
expect(overridden?.error).toContain("overridden by bundled plugin");
// Manifest registry deduplicates genuine duplicates (same id, different paths),
// keeping the bundled copy over the auto-discovered global copy.
expect(entries).toHaveLength(1);
expect(entries[0]?.status).toBe("loaded");
expect(entries[0]?.origin).toBe("bundled");
});
});

View File

@ -47,6 +47,17 @@ function countDuplicateWarnings(registry: ReturnType<typeof loadPluginManifestRe
).length;
}
function getDuplicateWarningMessages(
registry: ReturnType<typeof loadPluginManifestRegistry>,
): string[] {
return registry.diagnostics
.filter(
(diagnostic) =>
diagnostic.level === "warn" && diagnostic.message?.includes("duplicate plugin id"),
)
.map((diagnostic) => diagnostic.message);
}
function prepareLinkedManifestFixture(params: { id: string; mode: "symlink" | "hardlink" }): {
rootDir: string;
linked: boolean;
@ -150,7 +161,101 @@ 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);
// Bundled has higher precedence than global for genuine duplicates
// (bundled ships with the binary and is the known-good version)
expect(registry.plugins[0]?.origin).toBe("bundled");
expect(getDuplicateWarningMessages(registry)).toContain(
`duplicate plugin id detected; skipping duplicate from ${candidates[1].source}`,
);
});
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");
expect(getDuplicateWarningMessages(registry)).toContain(
`duplicate plugin id detected; skipping duplicate from ${candidates[1].source}`,
);
});
it("warns with existing source when higher-precedence duplicate replaces it", () => {
const globalDir = makeTempDir();
const bundledDir = makeTempDir();
const manifest = { id: "replace-prec", configSchema: { type: "object" } };
writeManifest(globalDir, manifest);
writeManifest(bundledDir, manifest);
const candidates: PluginCandidate[] = [
createPluginCandidate({
idHint: "replace-prec",
rootDir: globalDir,
origin: "global",
}),
createPluginCandidate({
idHint: "replace-prec",
rootDir: bundledDir,
origin: "bundled",
}),
];
const registry = loadRegistry(candidates);
expect(countDuplicateWarnings(registry)).toBe(1);
expect(registry.plugins.filter((p) => p.id === "replace-prec")).toHaveLength(1);
expect(registry.plugins[0]?.origin).toBe("bundled");
expect(getDuplicateWarningMessages(registry)).toContain(
`duplicate plugin id detected; skipping duplicate from ${candidates[0].source}`,
);
});
it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => {

View File

@ -12,7 +12,8 @@ type SeenIdEntry = {
recordIndex: number;
};
// Precedence: config > workspace > global > bundled
// Same-path dedup precedence: config > workspace > global > bundled
// Used when the same physical directory is discovered via multiple routes.
const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
config: 0,
workspace: 1,
@ -20,6 +21,17 @@ const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
bundled: 3,
};
// Genuine-duplicate precedence: config > bundled > workspace > global
// Used when genuinely different copies of a plugin (same id, different paths)
// exist. Bundled plugins ship with the binary and are the known-good version,
// so they take precedence over auto-discovered global/npm-installed copies.
const GENUINE_DUPLICATE_RANK: Readonly<Record<PluginOrigin, number>> = {
config: 0,
bundled: 1,
workspace: 2,
global: 3,
};
export type PluginManifestRecord = {
id: string;
name?: string;
@ -229,12 +241,30 @@ export function loadPluginManifestRegistry(params: {
}
continue;
}
const candidateWins =
GENUINE_DUPLICATE_RANK[candidate.origin] <
GENUINE_DUPLICATE_RANK[existing.candidate.origin];
const skippedCandidate = candidateWins ? existing.candidate : candidate;
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: candidate.source,
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
source: skippedCandidate.source,
message: `duplicate plugin id detected; skipping duplicate from ${skippedCandidate.source}`,
});
// Genuine duplicate from a different physical path: apply
// bundled-first precedence to avoid registering two records with
// the same plugin id (which causes Gateway instability).
if (candidateWins) {
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 });
}