From f2730833784efb1d13d6478f503a46ea1b9243b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 12:26:33 +0100 Subject: [PATCH] fix(doctor): match stale plugin records exactly --- CHANGELOG.md | 1 + .../missing-configured-plugin-install.test.ts | 77 +++++++++++++++++++ .../missing-configured-plugin-install.ts | 23 +++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e142f32bf0..0b5dfb1cccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee. - Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc. - CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the official npm spec during the externalization rollout. Thanks @vincentkoc. +- CLI/doctor: match stale bundled-plugin install records by exact parsed package name so doctor does not remove external npm or ClawHub records that only share an OpenClaw package-name prefix. - Plugins/catalog: preserve ClawHub install specs when generating the packaged channel catalog so future storepack-first channel plugins keep their remote source instead of becoming npm-only. Thanks @vincentkoc. - Plugins/catalog: pin bare npm specs from prerelease external channel catalog entries to the catalog entry version, so beta catalogs do not silently install the latest stable package. - Plugins/update: treat catalog-matched official npm updates and OpenClaw-authored externalized-bundled npm bridges as trusted official installs so launch-code plugins can update or migrate out of the bundled tree without scanner false positives. 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 dd69f285b4d..24277f232a7 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -629,6 +629,83 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); + it.each([ + [ + "npm", + { + source: "npm", + spec: "@openclaw/matrix-fork", + resolvedName: "@openclaw/matrix-fork", + resolvedSpec: "@openclaw/matrix-fork@1.2.3", + installPath: "/missing/matrix-fork", + }, + ], + [ + "clawhub", + { + source: "clawhub", + spec: "clawhub:@openclaw/matrix-fork@stable", + clawhubPackage: "@openclaw/matrix-fork", + installPath: "/missing/matrix-fork", + }, + ], + ])( + "keeps %s install records whose package names only share a bundled prefix", + async (_, record) => { + const records = { matrix: record }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + origin: "bundled", + meta: { label: "Matrix" }, + install: { + npmSpec: "@openclaw/matrix", + }, + }, + ]); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "matrix", + origin: "bundled", + packageName: "@openclaw/matrix", + channels: ["matrix"], + }, + ], + diagnostics: [ + { + pluginId: "matrix", + message: "manifest without channelConfigs metadata", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + matrix: { enabled: true }, + }, + }, + channels: { + matrix: { enabled: true, homeserver: "https://matrix.example.org" }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(result).toEqual({ changes: [], warnings: [] }); + }, + ); + it("defers missing external payload repair during the package update doctor pass", 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 90d905b9d1d..0ff14ba7799 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -7,6 +7,8 @@ import { import { listChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../../config/types.plugins.js"; +import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js"; +import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js"; import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js"; import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js"; import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js"; @@ -354,18 +356,31 @@ function recordMatchesBundledPackage( return false; } if (record.source === "npm") { - return [record.spec, record.resolvedName, record.resolvedSpec].some((value) => - value?.trim().startsWith(packageName), + return [record.spec, record.resolvedName, record.resolvedSpec].some( + (value) => recordNpmPackageName(value) === packageName, ); } if (record.source === "clawhub") { - return [record.clawhubPackage, record.spec].some((value) => - value?.trim().includes(packageName), + return [record.clawhubPackage, record.spec].some( + (value) => recordClawHubPackageName(value) === packageName, ); } return false; } +function recordNpmPackageName(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? parseRegistryNpmSpec(trimmed)?.name : undefined; +} + +function recordClawHubPackageName(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + return parseClawHubPluginSpec(trimmed)?.name ?? trimmed; +} + async function installCandidate(params: { candidate: DownloadableInstallCandidate; records: Record;