fix(plugins): prefer explicit installs over bundled duplicates (#46722)

* fix(plugins): prefer explicit installs over bundled duplicates

* test(feishu): mock structured card sends in outbound tests

* fix(plugins): align duplicate diagnostics with loader precedence
This commit is contained in:
Tak Hoffman 2026-03-14 21:08:32 -05:00 committed by GitHub
parent e3b7ff2f1f
commit f00db91590
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 269 additions and 7 deletions

View File

@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
### Fixes ### Fixes

View File

@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./media.js", () => ({ vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock, sendMediaFeishu: sendMediaFeishuMock,
@ -14,6 +15,7 @@ vi.mock("./media.js", () => ({
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock, sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
})); }));
vi.mock("./runtime.js", () => ({ vi.mock("./runtime.js", () => ({
@ -33,6 +35,7 @@ function resetOutboundMocks() {
vi.clearAllMocks(); vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
} }
@ -132,7 +135,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
accountId: "main", accountId: "main",
}); });
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: "chat_1", to: "chat_1",
text: "| a | b |\n| - | - |", text: "| a | b |\n| - | - |",
@ -207,7 +210,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
); );
}); });
it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => { it("forwards replyToId to sendStructuredCardFeishu when renderMode=card", async () => {
await sendText({ await sendText({
cfg: { cfg: {
channels: { channels: {
@ -222,7 +225,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
accountId: "main", accountId: "main",
}); });
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
replyToMessageId: "om_reply_target", replyToMessageId: "om_reply_target",
}), }),

View File

@ -1543,6 +1543,54 @@ describe("loadOpenClawPlugins", () => {
}); });
}); });
it("prefers an explicitly installed global plugin over a bundled duplicate", () => {
const bundledDir = makeTempDir();
writePlugin({
id: "zalouser",
body: `module.exports = { id: "zalouser", register() {} };`,
dir: bundledDir,
filename: "index.cjs",
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
const stateDir = makeTempDir();
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
const globalDir = path.join(stateDir, "extensions", "zalouser");
mkdirSafe(globalDir);
writePlugin({
id: "zalouser",
body: `module.exports = { id: "zalouser", register() {} };`,
dir: globalDir,
filename: "index.cjs",
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
allow: ["zalouser"],
installs: {
zalouser: {
source: "npm",
installPath: globalDir,
},
},
entries: {
zalouser: { enabled: true },
},
},
},
});
const entries = registry.plugins.filter((entry) => entry.id === "zalouser");
const loaded = entries.find((entry) => entry.status === "loaded");
const overridden = entries.find((entry) => entry.status === "disabled");
expect(loaded?.origin).toBe("global");
expect(overridden?.origin).toBe("bundled");
expect(overridden?.error).toContain("overridden by global plugin");
});
});
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => { it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
useNoBundledPlugins(); useNoBundledPlugins();
const plugin = writePlugin({ const plugin = writePlugin({

View File

@ -453,6 +453,78 @@ function isTrackedByProvenance(params: {
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath); return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
} }
function matchesExplicitInstallRule(params: {
pluginId: string;
source: string;
index: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): boolean {
const sourcePath = resolveUserPath(params.source, params.env);
const installRule = params.index.installRules.get(params.pluginId);
if (!installRule || installRule.trackedWithoutPaths) {
return false;
}
return matchesPathMatcher(installRule.matcher, sourcePath);
}
function resolveCandidateDuplicateRank(params: {
candidate: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
manifestByRoot: Map<string, ReturnType<typeof loadPluginManifestRegistry>["plugins"][number]>;
provenance: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): number {
const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir);
const pluginId = manifestRecord?.id;
const isExplicitInstall =
params.candidate.origin === "global" &&
pluginId !== undefined &&
matchesExplicitInstallRule({
pluginId,
source: params.candidate.source,
index: params.provenance,
env: params.env,
});
switch (params.candidate.origin) {
case "config":
return 0;
case "workspace":
return 1;
case "global":
return isExplicitInstall ? 2 : 4;
case "bundled":
return 3;
}
}
function compareDuplicateCandidateOrder(params: {
left: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
right: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
manifestByRoot: Map<string, ReturnType<typeof loadPluginManifestRegistry>["plugins"][number]>;
provenance: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): number {
const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id;
const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id;
if (!leftPluginId || leftPluginId !== rightPluginId) {
return 0;
}
return (
resolveCandidateDuplicateRank({
candidate: params.left,
manifestByRoot: params.manifestByRoot,
provenance: params.provenance,
env: params.env,
}) -
resolveCandidateDuplicateRank({
candidate: params.right,
manifestByRoot: params.manifestByRoot,
provenance: params.provenance,
env: params.env,
})
);
}
function warnWhenAllowlistIsOpen(params: { function warnWhenAllowlistIsOpen(params: {
logger: PluginLogger; logger: PluginLogger;
pluginsEnabled: boolean; pluginsEnabled: boolean;
@ -644,13 +716,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const manifestByRoot = new Map( const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]), manifestRegistry.plugins.map((record) => [record.rootDir, record]),
); );
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
return compareDuplicateCandidateOrder({
left,
right,
manifestByRoot,
provenance,
env,
});
});
const seenIds = new Map<string, PluginRecord["origin"]>(); const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory; const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null; let selectedMemoryPluginId: string | null = null;
let memorySlotMatched = false; let memorySlotMatched = false;
for (const candidate of discovery.candidates) { for (const candidate of orderedCandidates) {
const manifestRecord = manifestByRoot.get(candidate.rootDir); const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) { if (!manifestRecord) {
continue; continue;

View File

@ -155,6 +155,76 @@ describe("loadPluginManifestRegistry", () => {
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(1); expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(1);
}); });
it("reports explicit installed globals as the effective duplicate winner", () => {
const bundledDir = makeTempDir();
const globalDir = makeTempDir();
const manifest = { id: "zalouser", configSchema: { type: "object" } };
writeManifest(bundledDir, manifest);
writeManifest(globalDir, manifest);
const registry = loadPluginManifestRegistry({
cache: false,
config: {
plugins: {
installs: {
zalouser: {
source: "npm",
installPath: globalDir,
},
},
},
},
candidates: [
createPluginCandidate({
idHint: "zalouser",
rootDir: bundledDir,
origin: "bundled",
}),
createPluginCandidate({
idHint: "zalouser",
rootDir: globalDir,
origin: "global",
}),
],
});
expect(
registry.diagnostics.some((diag) =>
diag.message.includes("bundled plugin will be overridden by global plugin"),
),
).toBe(true);
});
it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => {
const bundledDir = makeTempDir();
const globalDir = makeTempDir();
const manifest = { id: "feishu", configSchema: { type: "object" } };
writeManifest(bundledDir, manifest);
writeManifest(globalDir, manifest);
const registry = loadPluginManifestRegistry({
cache: false,
candidates: [
createPluginCandidate({
idHint: "feishu",
rootDir: bundledDir,
origin: "bundled",
}),
createPluginCandidate({
idHint: "feishu",
rootDir: globalDir,
origin: "global",
}),
],
});
expect(
registry.diagnostics.some((diag) =>
diag.message.includes("global plugin will be overridden by bundled plugin"),
),
).toBe(true);
});
it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => { it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => {
const realDir = makeTempDir(); const realDir = makeTempDir();
const manifest = { id: "feishu", configSchema: { type: "object" } }; const manifest = { id: "feishu", configSchema: { type: "object" } };

View File

@ -1,9 +1,10 @@
import fs from "node:fs"; import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js"; import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import { safeRealpathSync } from "./path-safety.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js";
import { resolvePluginCacheInputs } from "./roots.js"; import { resolvePluginCacheInputs } from "./roots.js";
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js"; import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
@ -12,7 +13,7 @@ type SeenIdEntry = {
recordIndex: number; recordIndex: number;
}; };
// Precedence: config > workspace > global > bundled // Precedence: config > workspace > explicit-install global > bundled > auto-discovered global
const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = { const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
config: 0, config: 0,
workspace: 1, workspace: 1,
@ -135,6 +136,50 @@ function buildRecord(params: {
}; };
} }
function matchesInstalledPluginRecord(params: {
pluginId: string;
candidate: PluginCandidate;
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): boolean {
if (params.candidate.origin !== "global") {
return false;
}
const record = params.config?.plugins?.installs?.[params.pluginId];
if (!record) {
return false;
}
const candidateSource = resolveUserPath(params.candidate.source, params.env);
const trackedPaths = [record.installPath, record.sourcePath]
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => resolveUserPath(entry, params.env));
if (trackedPaths.length === 0) {
return false;
}
return trackedPaths.some((trackedPath) => {
return candidateSource === trackedPath || isPathInside(trackedPath, candidateSource);
});
}
function resolveDuplicatePrecedenceRank(params: {
pluginId: string;
candidate: PluginCandidate;
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): number {
if (params.candidate.origin === "global") {
return matchesInstalledPluginRecord({
pluginId: params.pluginId,
candidate: params.candidate,
config: params.config,
env: params.env,
})
? 2
: 4;
}
return PLUGIN_ORIGIN_RANK[params.candidate.origin];
}
export function loadPluginManifestRegistry(params: { export function loadPluginManifestRegistry(params: {
config?: OpenClawConfig; config?: OpenClawConfig;
workspaceDir?: string; workspaceDir?: string;
@ -237,7 +282,21 @@ export function loadPluginManifestRegistry(params: {
level: "warn", level: "warn",
pluginId: manifest.id, pluginId: manifest.id,
source: candidate.source, source: candidate.source,
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`, message:
resolveDuplicatePrecedenceRank({
pluginId: manifest.id,
candidate,
config,
env,
}) <
resolveDuplicatePrecedenceRank({
pluginId: manifest.id,
candidate: existing.candidate,
config,
env,
})
? `duplicate plugin id detected; ${existing.candidate.origin} plugin will be overridden by ${candidate.origin} plugin (${candidate.source})`
: `duplicate plugin id detected; ${candidate.origin} plugin will be overridden by ${existing.candidate.origin} plugin (${candidate.source})`,
}); });
} else { } else {
seenIds.set(manifest.id, { candidate, recordIndex: records.length }); seenIds.set(manifest.id, { candidate, recordIndex: records.length });