fix(plugins): warn when npm install is shadowed

This commit is contained in:
Yuyummy
2026-05-03 18:20:05 +08:00
committed by Peter Steinberger
parent 0459bff556
commit f73a614d66
4 changed files with 177 additions and 1 deletions

View File

@@ -78,6 +78,40 @@ describe("plugins cli list", () => {
expect(runtimeLogs).toContain("No plugin issues detected.");
});
it("reports config-selected plugin source shadowing in doctor output", async () => {
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [
createPluginRecord({
id: "discord",
origin: "config",
source: "/tmp/openclaw-upstream/extensions/discord/index.ts",
status: "error",
error: "Cannot find module 'chalk'",
}),
],
diagnostics: [
{
level: "warn",
pluginId: "discord",
source: "/tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts",
message:
"duplicate plugin id resolved by explicit config-selected plugin; global plugin will be overridden by config plugin (/tmp/openclaw-upstream/extensions/discord/index.ts)",
},
],
});
await runPluginsCommand(["plugins", "doctor"]);
const output = runtimeLogs.join("\n");
expect(output).toContain("Plugin source shadowing:");
expect(output).toContain(
"discord: duplicate plugin id resolved by explicit config-selected plugin",
);
expect(output).toContain("active: /tmp/openclaw-upstream/extensions/discord/index.ts");
expect(output).toContain("shadowed: /tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts");
expect(output).toContain("openclaw plugins registry --refresh");
});
it("reports persisted plugin registry state without refreshing", async () => {
inspectPluginRegistry.mockResolvedValue({
state: "stale",

View File

@@ -5,6 +5,7 @@ import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trac
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomeInString } from "../utils.js";
import type { PluginInspectOptions } from "./plugins-inspect-command.js";
import type { PluginsListOptions } from "./plugins-list-command.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
@@ -62,6 +63,14 @@ function matchesPluginId(plugin: { id: string }, id: string) {
return plugin.id === id;
}
function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: string }): boolean {
return (
entry.level === "warn" &&
typeof entry.message === "string" &&
entry.message.includes("duplicate plugin id resolved by explicit config-selected plugin")
);
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -336,9 +345,15 @@ export function registerPluginsCli(program: Command) {
const report = buildPluginDiagnosticsReport({ effectiveOnly: true });
const errors = report.plugins.filter((p) => p.status === "error");
const diags = report.diagnostics.filter((d) => d.level === "error");
const shadowed = report.diagnostics.filter(isConfigSelectedShadowDiagnostic);
const compatibility = buildPluginCompatibilityNotices({ report });
if (errors.length === 0 && diags.length === 0 && compatibility.length === 0) {
if (
errors.length === 0 &&
diags.length === 0 &&
shadowed.length === 0 &&
compatibility.length === 0
) {
defaultRuntime.log("No plugin issues detected.");
return;
}
@@ -361,6 +376,31 @@ export function registerPluginsCli(program: Command) {
lines.push(`- ${target}${diag.message}`);
}
}
if (shadowed.length > 0) {
if (lines.length > 0) {
lines.push("");
}
lines.push(theme.warn("Plugin source shadowing:"));
for (const diag of shadowed) {
const active = report.plugins.find((plugin) => plugin.id === diag.pluginId);
const target = diag.pluginId ? `${diag.pluginId}: ` : "";
lines.push(`- ${target}${diag.message}`);
if (active) {
lines.push(` active: ${shortenHomeInString(active.source)} (${active.origin})`);
if (active.status === "error") {
lines.push(` active status: error${active.error ? `: ${active.error}` : ""}`);
}
}
if (diag.source) {
lines.push(` shadowed: ${shortenHomeInString(diag.source)}`);
}
lines.push(" repair:");
lines.push(" openclaw plugins inspect " + (diag.pluginId ?? "<plugin-id>"));
lines.push(" edit or remove the config-selected plugin source");
lines.push(" openclaw plugins registry --refresh");
lines.push(" openclaw gateway restart --force");
}
}
if (compatibility.length > 0) {
if (lines.length > 0) {
lines.push("");

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
applyExclusiveSlotSelection,
buildPluginDiagnosticsReport,
buildPluginSnapshotReport,
clearPluginRegistryLoadCache,
enablePluginInConfig,
loadPluginManifestRegistry,
@@ -124,6 +125,64 @@ describe("persistPluginInstall", () => {
).toBe(true);
});
it("warns when an installed npm plugin remains shadowed by a config-selected source", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
discord: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
buildPluginSnapshotReport.mockReturnValue({
plugins: [
{
id: "discord",
origin: "config",
source: "/tmp/openclaw-upstream/extensions/discord/index.ts",
status: "error",
},
],
diagnostics: [],
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "discord",
install: {
source: "npm",
spec: "@openclaw/discord",
installPath: "/tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts",
},
});
expect(next).toEqual(enabledConfig);
expect(buildPluginSnapshotReport).toHaveBeenCalledWith({
config: enabledConfig,
effectiveOnly: true,
onlyPluginIds: ["discord"],
});
expect(runtimeLogs.join("\n")).toContain(
'Warning: installed plugin "discord" is not the active source',
);
expect(runtimeLogs.join("\n")).toContain(
"active config source: /tmp/openclaw-upstream/extensions/discord/index.ts",
);
expect(runtimeLogs.join("\n")).toContain(
"installed npm source: /tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts",
);
expect(runtimeLogs.join("\n")).toContain("openclaw plugins doctor");
});
it("invalidates runtime cache even when registry refresh fails", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {

View File

@@ -9,8 +9,10 @@ import {
} from "../plugins/installed-plugin-index-records.js";
import type { PluginInstallUpdate } from "../plugins/installs.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { buildPluginSnapshotReport } from "../plugins/status.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import {
applySlotSelectionForPlugin,
enableInternalHookEntries,
@@ -58,6 +60,41 @@ export type ConfigSnapshotForInstallPersist = {
baseHash: string | undefined;
};
function logShadowedNpmInstallWarning(params: {
config: OpenClawConfig;
pluginId: string;
install: Omit<PluginInstallUpdate, "pluginId">;
runtime: RuntimeEnv;
}): void {
if (params.install.source !== "npm") {
return;
}
const installedSource = params.install.installPath ?? params.install.sourcePath;
if (!installedSource) {
return;
}
const report = buildPluginSnapshotReport({
config: params.config,
effectiveOnly: true,
onlyPluginIds: [params.pluginId],
});
const active = report.plugins.find((plugin) => plugin.id === params.pluginId);
if (!active || active.origin !== "config" || active.source === installedSource) {
return;
}
params.runtime.log(
theme.warn(
[
`Warning: installed plugin "${params.pluginId}" is not the active source because a config-selected plugin with the same id is currently selected:`,
` active config source: ${shortenHomePath(active.source)}`,
` installed npm source: ${shortenHomePath(installedSource)}`,
"Run `openclaw plugins doctor` for repair options.",
].join("\n"),
),
);
}
export async function persistPluginInstall(params: {
snapshot: ConfigSnapshotForInstallPersist;
pluginId: string;
@@ -127,6 +164,12 @@ export async function persistPluginInstall(params: {
runtime.log(theme.warn(params.warningMessage));
}
runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`);
logShadowedNpmInstallWarning({
config: next,
pluginId: params.pluginId,
install: params.install,
runtime,
});
runtime.log("Restart the gateway to load plugins.");
return next;
}