From 9765a5777c73b5bb9ec56608b242b138e90a9733 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 18:29:57 -0700 Subject: [PATCH] fix(doctor): defer channel plugin repair during updates --- .../missing-configured-plugin-install.test.ts | 84 +++++++++++++++++++ .../missing-configured-plugin-install.ts | 32 ++++++- 2 files changed, 114 insertions(+), 2 deletions(-) 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 63259ecb94f..db503396cee 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -630,6 +630,90 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); + it("defers channel-selected external payload repair during the package update doctor pass", async () => { + const records = { + discord: { + source: "npm", + spec: "@openclaw/discord", + installPath: "/missing/discord", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "discord", + pluginId: "discord", + meta: { label: "Discord" }, + install: { + npmSpec: "@openclaw/discord", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + discord: { enabled: true, token: "secret" }, + }, + }, + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, + }); + + expect(mocks.updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + {}, + { + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, + }, + ); + expect(result).toEqual({ + changes: [ + 'Deferred missing configured plugin "discord" install repair until post-update doctor.', + ], + warnings: [], + }); + }); + + it("does not install channel-selected external plugins during the package update doctor pass", async () => { + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "discord", + pluginId: "discord", + meta: { label: "Discord" }, + install: { + npmSpec: "@openclaw/discord", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + discord: { enabled: true, token: "secret" }, + }, + }, + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, + }); + + 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("does not install configured plugins when plugins are globally disabled", async () => { mocks.listChannelPluginCatalogEntries.mockReturnValue([ { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 44bc2ab3e70..bd1dad1c382 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -275,6 +275,27 @@ function collectDownloadableInstallCandidates(params: { ); } +function collectUpdateDeferredPluginIds(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + configuredPluginIds: ReadonlySet; + configuredChannelIds: ReadonlySet; + blockedPluginIds?: ReadonlySet; +}): Set { + const pluginIds = new Set(params.configuredPluginIds); + for (const candidate of collectDownloadableInstallCandidates({ + cfg: params.cfg, + env: params.env, + missingPluginIds: new Set(), + configuredPluginIds: params.configuredPluginIds, + configuredChannelIds: params.configuredChannelIds, + blockedPluginIds: params.blockedPluginIds, + })) { + pluginIds.add(candidate.pluginId); + } + return pluginIds; +} + function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: { snapshot: PluginMetadataSnapshot; configuredPluginIds: ReadonlySet; @@ -515,7 +536,15 @@ async function repairMissingPluginInstalls(params: { } if (isUpdatePackageDoctorPass(env)) { - for (const pluginId of params.pluginIds) { + const updateDeferredPluginIds = collectUpdateDeferredPluginIds({ + cfg: params.cfg, + env, + configuredPluginIds: params.pluginIds, + configuredChannelIds: params.channelIds, + blockedPluginIds: params.blockedPluginIds, + }); + for (const pluginId of updateDeferredPluginIds) { + deferredPluginIds.add(pluginId); const record = nextRecords[pluginId]; if (!record || !isInstalledRecordMissingOnDisk(record, env)) { continue; @@ -524,7 +553,6 @@ async function repairMissingPluginInstalls(params: { nextRecords = { ...records }; } delete nextRecords[pluginId]; - deferredPluginIds.add(pluginId); changes.push( `Deferred missing configured plugin "${pluginId}" install repair until post-update doctor.`, );