fix(plugins): tighten shadowed install diagnostics

This commit is contained in:
Peter Steinberger
2026-05-03 13:35:06 +01:00
parent f73a614d66
commit 68b56108f6
5 changed files with 105 additions and 3 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- Gateway/update: run `doctor --non-interactive --fix` after Control UI global package updates before reporting success, so legacy config is migrated before the gateway restart. Thanks @stevenchouai.
- Gateway/cron: stop a lazy cron startup that loses a hot-reload race, preventing the old cron service from starting after reload has already replaced cron state.
- CLI/plugins: warn when npm plugin installs remain shadowed by a failing config-selected source and surface the repair path in `plugins doctor`. Thanks @LindalyX-Lee.
- Active Memory: apply `setupGraceTimeoutMs` to the embedded recall runner as well as the outer prompt-build watchdog, so very-cold first recalls keep the configured setup grace end-to-end. (#74480) Thanks @volcano303.
- CLI/config: keep JSON dry-run patches validating touched channel configuration against bundled channel schemas even when the patch only contains SecretRef objects.
- Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art.

View File

@@ -112,6 +112,32 @@ describe("plugins cli list", () => {
expect(output).toContain("openclaw plugins registry --refresh");
});
it("does not report healthy config-selected plugin source shadowing as doctor issue", async () => {
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [
createPluginRecord({
id: "discord",
origin: "config",
source: "/tmp/openclaw-upstream/extensions/discord/index.ts",
status: "loaded",
}),
],
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"]);
expect(runtimeLogs).toContain("No plugin issues detected.");
});
it("reports persisted plugin registry state without refreshing", async () => {
inspectPluginRegistry.mockResolvedValue({
state: "stale",

View File

@@ -71,6 +71,21 @@ function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: str
);
}
function isErroredConfigSelectedShadowDiagnostic(params: {
entry: { level?: string; message?: string; pluginId?: string };
plugins: readonly { id: string; origin: string; status: string }[];
}): boolean {
if (!params.entry.pluginId || !isConfigSelectedShadowDiagnostic(params.entry)) {
return false;
}
return params.plugins.some(
(plugin) =>
plugin.id === params.entry.pluginId &&
plugin.origin === "config" &&
plugin.status === "error",
);
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -345,7 +360,9 @@ 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 shadowed = report.diagnostics.filter((entry) =>
isErroredConfigSelectedShadowDiagnostic({ entry, plugins: report.plugins }),
);
const compatibility = buildPluginCompatibilityNotices({ report });
if (

View File

@@ -183,6 +183,49 @@ describe("persistPluginInstall", () => {
expect(runtimeLogs.join("\n")).toContain("openclaw plugins doctor");
});
it("does not warn when the config-selected source is inside the npm install path", 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/npm/node_modules/@openclaw/discord/dist/index.js",
status: "loaded",
},
],
diagnostics: [],
});
await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "discord",
install: {
source: "npm",
spec: "@openclaw/discord",
installPath: "/tmp/openclaw/npm/node_modules/@openclaw/discord",
},
});
expect(runtimeLogs.join("\n")).not.toContain("is not the active source");
});
it("invalidates runtime cache even when registry refresh fails", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {

View File

@@ -1,6 +1,7 @@
import { replaceConfigFile } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js";
import { isPathInside } from "../infra/path-guards.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import {
loadInstalledPluginIndexInstallRecords,
@@ -12,7 +13,7 @@ import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trac
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 { resolveUserPath, shortenHomePath } from "../utils.js";
import {
applySlotSelectionForPlugin,
enableInternalHookEntries,
@@ -60,6 +61,16 @@ export type ConfigSnapshotForInstallPersist = {
baseHash: string | undefined;
};
function sourceMatchesInstalledPath(params: {
activeSource: string;
installedSource: string;
env?: NodeJS.ProcessEnv;
}): boolean {
const activeSource = resolveUserPath(params.activeSource, params.env);
const installedSource = resolveUserPath(params.installedSource, params.env);
return activeSource === installedSource || isPathInside(installedSource, activeSource);
}
function logShadowedNpmInstallWarning(params: {
config: OpenClawConfig;
pluginId: string;
@@ -79,7 +90,11 @@ function logShadowedNpmInstallWarning(params: {
onlyPluginIds: [params.pluginId],
});
const active = report.plugins.find((plugin) => plugin.id === params.pluginId);
if (!active || active.origin !== "config" || active.source === installedSource) {
if (
!active ||
active.origin !== "config" ||
sourceMatchesInstalledPath({ activeSource: active.source, installedSource })
) {
return;
}