diff --git a/CHANGELOG.md b/CHANGELOG.md index 261792a7af4..bdd4dfae0fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw. - CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries. - Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`. +- Doctor/plugins: repair configured external plugin installs whose persisted install record points at a missing package directory, so upgrades reconcile phantom npm metadata before plugin runtime validation. Thanks @vincentkoc. - Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits. - Plugins/externalization: repair missing configured plugin installs from npm by default, reserve ClawHub downloads for explicit `clawhubSpec` metadata, and cover agent-runtime/env-selected plugin repair. Thanks @vincentkoc. - Plugins/install: allow official catalog-matched npm channel plugins such as Feishu to pass the trusted install scanner path while keeping spoofed package names blocked. Thanks @vincentkoc. diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 0aca9579d10..2cd5aa32767 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -791,6 +791,79 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); }); + it("reinstalls a known configured plugin when its recorded install path is missing", async () => { + const records = { + discord: { + source: "npm", + spec: "@openclaw/discord", + installPath: "/tmp/openclaw-missing-discord-install-record", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "discord", + channels: ["discord"], + }, + ], + diagnostics: [], + }); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: true, + config: { + plugins: { + installs: { + discord: { + source: "npm", + spec: "@openclaw/discord", + installPath: "/tmp/openclaw-plugins/discord", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "discord", + status: "updated", + message: "Updated discord.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + discord: { enabled: true }, + }, + }, + channels: { + discord: { enabled: true }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["discord"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ installs: records }), + }), + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + discord: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/discord" }), + }), + { env: {} }, + ); + expect(result.changes).toEqual(['Repaired missing configured plugin "discord".']); + }); + it("updates a configured plugin when its installed manifest lacks channel config descriptors", async () => { const records = { discord: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 7fd9a9b143d..96e06bc7ebf 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, @@ -24,6 +26,7 @@ import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-sn import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; +import { resolveUserPath } from "../../../utils.js"; import { asObjectRecord } from "./object.js"; type DownloadableInstallCandidate = { @@ -290,6 +293,18 @@ function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: { return stalePluginIds; } +function isInstalledRecordMissingOnDisk( + record: PluginInstallRecord | undefined, + env: NodeJS.ProcessEnv, +): boolean { + const installPath = record?.installPath?.trim(); + if (!installPath) { + return true; + } + const resolved = resolveUserPath(installPath, env); + return !existsSync(path.join(resolved, "package.json")); +} + async function installCandidate(params: { candidate: DownloadableInstallCandidate; records: Record; @@ -445,7 +460,8 @@ async function repairMissingPluginInstalls(params: { const records = await loadInstalledPluginIndexInstallRecords({ env }); const missingRecordedPluginIds = Object.keys(records).filter( (pluginId) => - (params.pluginIds.has(pluginId) && !knownIds.has(pluginId)) || + (params.pluginIds.has(pluginId) && + (!knownIds.has(pluginId) || isInstalledRecordMissingOnDisk(records[pluginId], env))) || configuredPluginIdsWithStaleDescriptors.has(pluginId), ); const changes: string[] = [];