From b7ce9439e7f3fc3bd13b0bef31a920a20789f678 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 10:17:37 +0100 Subject: [PATCH] fix: repair bundled plugin shadow cleanup --- CHANGELOG.md | 2 +- docs/cli/plugins.md | 2 +- docs/gateway/doctor.md | 2 +- src/commands/doctor-plugin-registry.test.ts | 81 +++++++++++++++++++ src/commands/doctor-plugin-registry.ts | 8 +- src/commands/doctor/repair-sequencing.test.ts | 54 +++++++++++++ src/commands/doctor/repair-sequencing.ts | 6 ++ .../missing-configured-plugin-install.test.ts | 80 ++++++++++++++++++ .../missing-configured-plugin-install.ts | 39 +++++++-- 9 files changed, 257 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 535d8ad2098..951fad963d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,7 @@ Docs: https://docs.openclaw.ai - Canvas host: preserve the Gateway TLS scheme in browser canvas host URLs and startup mount logs, so direct HTTPS gateways do not advertise insecure canvas links. Thanks @vincentkoc. - WhatsApp/login: route login success and failure messages through the injected runtime, so setup/onboarding surfaces capture all login output instead of only the QR. Thanks @vincentkoc. - Google Chat: create an isolated Google auth transport per auth client, so google-auth-library interceptor mutations do not accumulate across webhook verification and access-token clients. Thanks @vincentkoc. -- Doctor/plugins: remove orphaned managed npm copies of bundled `@openclaw/*` plugins during `doctor --fix`, so stale package manifests cannot shadow the current bundled plugin config schema. +- Doctor/plugins: remove orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins during `doctor --fix`, so stale package manifests cannot shadow the current bundled plugin config schema. - Control UI/performance: cap long-task and long-animation-frame diagnostics in the shared event log, so slow-render telemetry does not evict gateway/plugin events from the Debug and Overview views. Thanks @vincentkoc. - Gateway/startup: log the canvas host mount only after the HTTP server has bound, so startup logs no longer report the canvas host as mounted before it can serve requests. - Control UI/i18n: render the Sessions active filter tooltip with the configured minute count in every locale and make the i18n check reject placeholder drift. Thanks @BunsDev. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 6477a06a03b..9d98c22ec80 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -387,7 +387,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path. -`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. +`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 07c8d816c7e..3f4549c2c59 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -344,7 +344,7 @@ That stages grounded durable candidates into the short-term dreaming store while When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing. - Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. + Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. Doctor can also reinstall configured downloadable plugins when the config references them but the local plugin registry cannot find them. For the 2026.5.2 bundled-plugin externalization, doctor automatically installs downloadable plugins that the existing config already uses and then relies on `meta.lastTouchedVersion` to run that release pass only once. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work. diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index 741a6ac3b0a..53dc318c299 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -188,6 +188,28 @@ function createCurrentIndex(): InstalledPluginIndex { }; } +function createCurrentIndexWithNpmRecord(params: { + pluginId: string; + packageName: string; + packageDir: string; + version: string; +}): InstalledPluginIndex { + return { + ...createCurrentIndex(), + installRecords: { + [params.pluginId]: { + source: "npm", + spec: `${params.packageName}@${params.version}`, + installPath: params.packageDir, + version: params.version, + resolvedName: params.packageName, + resolvedVersion: params.version, + resolvedSpec: `${params.packageName}@${params.version}`, + }, + }, + }; +} + describe("maybeRepairPluginRegistryState", () => { it("refreshes an existing registry during repair", async () => { const stateDir = makeTempDir(); @@ -334,6 +356,65 @@ describe("maybeRepairPluginRegistryState", () => { ); }); + it("removes recovered npm install records when a managed package shadows a bundled plugin", async () => { + const stateDir = makeTempDir(); + const bundledDir = path.join(stateDir, "bundled", "google-meet"); + fs.mkdirSync(bundledDir, { recursive: true }); + const managed = createManagedNpmPlugin({ + stateDir, + id: "google-meet", + packageName: "@openclaw/google-meet", + version: "2026.5.3", + }); + await writePersistedInstalledPluginIndex( + createCurrentIndexWithNpmRecord({ + pluginId: "google-meet", + packageName: "@openclaw/google-meet", + packageDir: managed.packageDir, + version: "2026.5.3", + }), + { stateDir }, + ); + + await maybeRepairPluginRegistryState({ + stateDir, + candidates: [ + createBundledCandidate({ + rootDir: bundledDir, + id: "google-meet", + packageName: "@openclaw/google-meet", + version: "2026.5.3", + }), + ], + env: hermeticEnv(), + config: { + plugins: { + allow: ["google-meet"], + entries: { + "google-meet": { + enabled: true, + config: {}, + }, + }, + }, + }, + prompter: { shouldRepair: true }, + }); + + expect(fs.existsSync(managed.packageDir)).toBe(false); + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({ + installRecords: {}, + refreshReason: "migration", + plugins: [ + expect.objectContaining({ + pluginId: "google-meet", + origin: "bundled", + rootDir: bundledDir, + }), + ], + }); + }); + it("removes stale managed npm packages from the package lock during repair", async () => { const stateDir = makeTempDir(); const bundledDir = path.join(stateDir, "bundled", "google-meet"); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 029ee42dc25..dd97d7a6366 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -5,7 +5,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { saveJsonFile } from "../infra/json-file.js"; import { resolveDefaultPluginNpmDir } from "../plugins/install-paths.js"; import type { InstalledPluginIndexRecordStoreOptions } from "../plugins/installed-plugin-index-records.js"; -import { readPersistedInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-records.js"; import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js"; import { refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { note } from "../terminal/note.js"; @@ -81,7 +80,6 @@ function readPluginManifestId(packageDir: string): string | undefined { function listStaleManagedNpmBundledPlugins( params: PluginRegistryDoctorRepairParams, ): StaleManagedNpmBundledPlugin[] { - const persistedInstallRecords = readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {}; const currentBundled = loadInstalledPluginIndex({ ...params, installRecords: {}, @@ -109,10 +107,6 @@ function listStaleManagedNpmBundledPlugins( if (!pluginId || pluginId !== bundled.pluginId) { continue; } - const persistedRecord = persistedInstallRecords[pluginId]; - if (persistedRecord?.source === "npm") { - continue; - } stale.push({ pluginId, packageName, @@ -195,7 +189,7 @@ function removeManagedNpmPackageLockDependency(params: { } } -function maybeRepairStaleManagedNpmBundledPlugins( +export function maybeRepairStaleManagedNpmBundledPlugins( params: PluginRegistryDoctorRepairParams, ): boolean { const stale = listStaleManagedNpmBundledPlugins(params); diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index d07bdfd0326..2c828889cc8 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -4,6 +4,7 @@ import { runDoctorRepairSequence } from "./repair-sequencing.js"; const mocks = vi.hoisted(() => ({ applyPluginAutoEnable: vi.fn(), + maybeRepairStaleManagedNpmBundledPlugins: vi.fn(), maybeRepairStalePluginConfig: vi.fn(), repairMissingConfiguredPluginInstalls: vi.fn(), })); @@ -12,6 +13,10 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: mocks.applyPluginAutoEnable, })); +vi.mock("../doctor-plugin-registry.js", () => ({ + maybeRepairStaleManagedNpmBundledPlugins: mocks.maybeRepairStaleManagedNpmBundledPlugins, +})); + vi.mock("./shared/missing-configured-plugin-install.js", () => ({ repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls, })); @@ -145,6 +150,7 @@ describe("doctor repair sequencing", () => { config: params.config, changes: [], })); + mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false); mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], warnings: [], @@ -228,6 +234,54 @@ describe("doctor repair sequencing", () => { expect(result.warningNotes.join("\n")).not.toContain("\r"); }); + it("removes managed npm bundled-plugin shadows before missing plugin install repair", async () => { + const events: string[] = []; + mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(() => { + events.push("cleanup"); + return true; + }); + mocks.repairMissingConfiguredPluginInstalls.mockImplementation(async () => { + events.push("missing-installs"); + return { changes: [], warnings: [] }; + }); + + await runDoctorRepairSequence({ + state: { + cfg: { + plugins: { + entries: { + "google-meet": { enabled: true }, + }, + }, + } as OpenClawConfig, + candidate: { + plugins: { + entries: { + "google-meet": { enabled: true }, + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(events).toEqual(["cleanup", "missing-installs"]); + expect(mocks.maybeRepairStaleManagedNpmBundledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + "google-meet": { enabled: true }, + }), + }), + }), + prompter: { shouldRepair: true }, + }), + ); + }); + it("emits Discord warnings when unsafe numeric ids block repair", async () => { const result = await runDoctorRepairSequence({ state: { diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index a1748f091b9..bc47164e7f4 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -1,5 +1,6 @@ import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; +import { maybeRepairStaleManagedNpmBundledPlugins } from "../doctor-plugin-registry.js"; import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js"; import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js"; import { @@ -67,6 +68,11 @@ export async function runDoctorRepairSequence(params: { } applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate)); applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env)); + maybeRepairStaleManagedNpmBundledPlugins({ + config: state.candidate, + env, + prompter: { shouldRepair: true }, + }); const missingConfiguredPluginInstallRepair = await repairMissingConfiguredPluginInstalls({ cfg: state.candidate, env, 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 86422b2986a..d102649ff55 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ installPluginFromNpmSpec: vi.fn(), listChannelPluginCatalogEntries: vi.fn(), listOfficialExternalPluginCatalogEntries: vi.fn(), + loadInstalledPluginIndex: vi.fn(), loadInstalledPluginIndexInstallRecords: vi.fn(), loadPluginMetadataSnapshot: vi.fn(), getOfficialExternalPluginCatalogManifest: vi.fn( @@ -33,6 +34,11 @@ vi.mock("../../../plugins/installed-plugin-index-records.js", () => ({ mocks.writePersistedInstalledPluginIndexInstallRecords, })); +vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadInstalledPluginIndex: mocks.loadInstalledPluginIndex, +})); + vi.mock("../../../plugins/install-paths.js", () => ({ resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir, })); @@ -76,6 +82,11 @@ describe("repairMissingConfiguredPluginInstalls", () => { plugins: [], diagnostics: [], }); + mocks.loadInstalledPluginIndex.mockReturnValue({ + plugins: [], + diagnostics: [], + installRecords: {}, + }); mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({}); mocks.listChannelPluginCatalogEntries.mockReturnValue([]); mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([]); @@ -663,6 +674,75 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); + it("uses current bundled discovery to remove records before stale snapshots can reinstall official plugins", async () => { + const records = { + "google-meet": { + source: "npm", + spec: "@openclaw/google-meet", + resolvedName: "@openclaw/google-meet", + installPath: "/missing/google-meet", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "google-meet", + origin: "npm", + packageName: "@openclaw/google-meet", + }, + ], + diagnostics: [], + }); + mocks.loadInstalledPluginIndex.mockReturnValue({ + plugins: [ + { + pluginId: "google-meet", + origin: "bundled", + packageName: "@openclaw/google-meet", + }, + ], + diagnostics: [], + installRecords: {}, + }); + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "google-meet", + label: "Google Meet", + install: { npmSpec: "@openclaw/google-meet" }, + openclaw: { + id: "google-meet", + install: { npmSpec: "@openclaw/google-meet" }, + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + "google-meet": { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + {}, + { + env: {}, + }, + ); + expect(result).toEqual({ + changes: ['Removed stale managed install record for bundled plugin "google-meet".'], + warnings: [], + }); + }); + it.each([ [ "npm", diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index ae4a5cca060..7c285e14fb7 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -16,9 +16,9 @@ import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-path import { installPluginFromNpmSpec } from "../../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; +import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js"; import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js"; import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; -import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js"; import type { PluginPackageInstall } from "../../../plugins/manifest.js"; import { listOfficialExternalPluginCatalogEntries, @@ -44,6 +44,11 @@ type DownloadableInstallCandidate = { defaultChoice?: PluginPackageInstall["defaultChoice"]; }; +type BundledPluginPackageDescriptor = { + name?: string; + packageName?: string; +}; + const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] = [ { pluginId: "acpx", @@ -417,7 +422,7 @@ function isUpdatePackageDoctorPass(env: NodeJS.ProcessEnv): boolean { function recordMatchesBundledPackage( record: PluginInstallRecord, - bundled: PluginManifestRecord, + bundled: BundledPluginPackageDescriptor, ): boolean { const packageName = bundled.packageName?.trim() || bundled.name?.trim(); if (!packageName) { @@ -598,18 +603,35 @@ async function repairMissingPluginInstalls(params: { config: params.cfg, env, }); - const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id)); + const currentBundledPlugins = loadInstalledPluginIndex({ + config: params.cfg, + env, + installRecords: {}, + }).plugins.filter((plugin) => plugin.origin === "bundled"); + const knownIds = new Set([ + ...snapshot.plugins.map((plugin) => plugin.id), + ...currentBundledPlugins.map((plugin) => plugin.pluginId), + ]); const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({ cfg: params.cfg, env, snapshot, configuredChannelIds: params.channelIds, }); - const bundledPluginsById = new Map( - snapshot.plugins + const bundledPluginsById = new Map([ + ...snapshot.plugins .filter((plugin) => plugin.origin === "bundled") - .map((plugin) => [plugin.id, plugin]), - ); + .map((plugin) => [plugin.id, plugin] as const), + ...currentBundledPlugins.map( + (plugin) => + [ + plugin.pluginId, + { + packageName: plugin.packageName, + }, + ] as const, + ), + ]); const configuredPluginIdsWithStaleDescriptors = collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({ snapshot, @@ -724,6 +746,9 @@ async function repairMissingPluginInstalls(params: { ? new Set([...(params.blockedPluginIds ?? []), ...deferredPluginIds]) : params.blockedPluginIds, })) { + if (bundledPluginsById.has(candidate.pluginId)) { + continue; + } const hasUsableRecord = Object.hasOwn(nextRecords, candidate.pluginId) && !isInstalledRecordMissingOnDisk(nextRecords[candidate.pluginId], env);