diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a5bffc9f7..645c7590930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index 5abd73f54ba..ec820743743 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -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", diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 10eded74098..9c254c44c23 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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 ( diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 4ff4334e3ed..b981171a6b3 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -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 = { diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index b032388ee5d..4c787c1b5c4 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -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; }