From 57c37ef9333c52bb686eb6d14a95af02291e4c1b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 02:24:30 +0100 Subject: [PATCH] fix(doctor): respect channel owner plugin repairs --- CHANGELOG.md | 1 + .../missing-configured-plugin-install.test.ts | 246 ++++++++++++++++++ .../missing-configured-plugin-install.ts | 81 ++++++ 3 files changed, 328 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0de02e019f..524c1458379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc. - Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc. +- Doctor/plugins: skip channel-derived official plugin installs when another configured plugin is the effective owner for the same channel, so `doctor --repair` does not reinstall `feishu` while `openclaw-lark` handles `channels.feishu`. Fixes #76623. Thanks @fuyizheng3120. - Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946. - Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc. - Gateway/install: keep `.env`-managed values in the macOS LaunchAgent env file while still tracking `OPENCLAW_SERVICE_MANAGED_ENV_KEYS`, so regenerated services do not boot without managed auth/provider keys. Fixes #75374. 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 24277f232a7..3e12fd96519 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -514,6 +514,40 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result).toEqual({ changes: [], warnings: [] }); }); + it("does not install channel plugins when the matching plugin entry is disabled", async () => { + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + npmSpec: "@openclaw/plugin-matrix@1.2.3", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + matrix: { enabled: false }, + }, + }, + channels: { + matrix: { homeserver: "https://matrix.example.org" }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(result).toEqual({ changes: [], warnings: [] }); + }); + it("does not download configured channel plugins that are still bundled", async () => { mocks.listChannelPluginCatalogEntries.mockReturnValue([ { @@ -1126,6 +1160,218 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result).toEqual({ changes: [], warnings: [] }); }); + it("does not install a channel catalog plugin when a configured plugin already owns that channel", async () => { + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "openclaw-lark", + origin: "config", + channels: ["feishu"], + channelConfigs: { + feishu: { + schema: { + type: "object", + }, + }, + }, + }, + ], + diagnostics: [], + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "feishu", + pluginId: "feishu", + meta: { label: "Feishu" }, + install: { + npmSpec: "@openclaw/feishu", + }, + trustedSourceLinkedOfficialInstall: true, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + "openclaw-lark": { + enabled: true, + }, + }, + }, + channels: { + feishu: { + footer: { + model: false, + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(result).toEqual({ changes: [], warnings: [] }); + }); + + it("still installs a channel catalog plugin when the configured owner is blocked by the allowlist", async () => { + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "openclaw-lark", + origin: "config", + channels: ["feishu"], + channelConfigs: { + feishu: { + schema: { + type: "object", + }, + }, + }, + }, + ], + diagnostics: [], + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "feishu", + pluginId: "feishu", + meta: { label: "Feishu" }, + install: { + npmSpec: "@openclaw/feishu", + }, + trustedSourceLinkedOfficialInstall: true, + }, + ]); + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "feishu", + targetDir: "/tmp/openclaw-plugins/feishu", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/feishu", + version: "2026.5.2", + resolvedSpec: "@openclaw/feishu@2026.5.2", + }, + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + allow: ["some-other-plugin"], + entries: { + "openclaw-lark": { + enabled: true, + }, + }, + }, + channels: { + feishu: { + footer: { + model: false, + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/feishu", + expectedPluginId: "feishu", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "feishu" from @openclaw/feishu.', + ]); + }); + + it("still installs a channel catalog plugin when that plugin is explicitly configured", async () => { + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "openclaw-lark", + origin: "config", + channels: ["feishu"], + channelConfigs: { + feishu: { + schema: { + type: "object", + }, + }, + }, + }, + ], + diagnostics: [], + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "feishu", + pluginId: "feishu", + meta: { label: "Feishu" }, + install: { + npmSpec: "@openclaw/feishu", + }, + trustedSourceLinkedOfficialInstall: true, + }, + ]); + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "feishu", + targetDir: "/tmp/openclaw-plugins/feishu", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/feishu", + version: "2026.5.2", + resolvedSpec: "@openclaw/feishu@2026.5.2", + }, + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + feishu: { + enabled: true, + }, + "openclaw-lark": { + enabled: true, + }, + }, + }, + channels: { + feishu: { + footer: { + model: false, + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/feishu", + expectedPluginId: "feishu", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "feishu" from @openclaw/feishu.', + ]); + }); + it("reinstalls a missing configured plugin from its persisted install record", async () => { const records = { demo: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 0ff14ba7799..7e78f56be2d 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -9,6 +9,7 @@ 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 { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.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"; @@ -29,6 +30,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 { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { resolveUserPath } from "../../../utils.js"; import { asObjectRecord } from "./object.js"; @@ -139,6 +141,25 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv return ids; } +function collectBlockedPluginIds(cfg: OpenClawConfig): Set { + const ids = new Set(); + const deny = cfg.plugins?.deny; + if (Array.isArray(deny)) { + for (const pluginId of deny) { + if (typeof pluginId === "string" && pluginId.trim()) { + ids.add(pluginId.trim()); + } + } + } + const entries = asObjectRecord(cfg.plugins?.entries); + for (const [pluginId, entry] of Object.entries(entries ?? {})) { + if (pluginId.trim() && asObjectRecord(entry)?.enabled === false) { + ids.add(pluginId.trim()); + } + } + return ids; +} + function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): Set { const ids = new Set(); if (asObjectRecord(cfg.plugins)?.enabled === false) { @@ -161,12 +182,45 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEn return ids; } +function collectEffectiveConfiguredChannelOwnerPluginIds(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + snapshot: PluginMetadataSnapshot; + configuredChannelIds: ReadonlySet; +}): Map> { + const owners = new Map>(); + const configuredChannelIds = new Set( + [...params.configuredChannelIds] + .map((channelId) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)), + ); + if (configuredChannelIds.size === 0) { + return owners; + } + for (const entry of resolveConfiguredChannelPresencePolicy({ + config: params.cfg, + env: params.env, + includePersistedAuthState: false, + manifestRecords: params.snapshot.plugins, + })) { + if (!entry.effective || !configuredChannelIds.has(entry.channelId)) { + continue; + } + const pluginIds = new Set(entry.pluginIds); + if (pluginIds.size > 0) { + owners.set(entry.channelId, pluginIds); + } + } + return owners; +} + function collectDownloadableInstallCandidates(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; missingPluginIds: ReadonlySet; configuredPluginIds?: ReadonlySet; configuredChannelIds?: ReadonlySet; + configuredChannelOwnerPluginIds?: ReadonlyMap>; blockedPluginIds?: ReadonlySet; }): DownloadableInstallCandidate[] { const configuredPluginIds = @@ -183,9 +237,25 @@ function collectDownloadableInstallCandidates(params: { continue; } const pluginId = entry.pluginId ?? entry.id; + const channelId = normalizeOptionalLowercaseString(entry.id); if (params.blockedPluginIds?.has(pluginId)) { continue; } + const selectedOnlyByChannel = + !params.missingPluginIds.has(pluginId) && + !configuredPluginIds.has(pluginId) && + (channelId ? configuredChannelIds.has(channelId) : configuredChannelIds.has(entry.id)); + const configuredChannelOwnerPluginIds = channelId + ? params.configuredChannelOwnerPluginIds?.get(channelId) + : undefined; + if ( + selectedOnlyByChannel && + configuredChannelOwnerPluginIds && + configuredChannelOwnerPluginIds.size > 0 && + !configuredChannelOwnerPluginIds.has(pluginId) + ) { + continue; + } if ( !params.missingPluginIds.has(pluginId) && !configuredPluginIds.has(pluginId) && @@ -292,6 +362,7 @@ function collectUpdateDeferredPluginIds(params: { env: NodeJS.ProcessEnv; configuredPluginIds: ReadonlySet; configuredChannelIds: ReadonlySet; + configuredChannelOwnerPluginIds?: ReadonlyMap>; blockedPluginIds?: ReadonlySet; }): Set { const pluginIds = new Set(params.configuredPluginIds); @@ -301,6 +372,7 @@ function collectUpdateDeferredPluginIds(params: { missingPluginIds: new Set(), configuredPluginIds: params.configuredPluginIds, configuredChannelIds: params.configuredChannelIds, + configuredChannelOwnerPluginIds: params.configuredChannelOwnerPluginIds, blockedPluginIds: params.blockedPluginIds, })) { pluginIds.add(candidate.pluginId); @@ -488,6 +560,7 @@ export async function repairMissingConfiguredPluginInstalls(params: { env: params.env, pluginIds: collectConfiguredPluginIds(params.cfg, params.env), channelIds: collectConfiguredChannelIds(params.cfg, params.env), + blockedPluginIds: collectBlockedPluginIds(params.cfg), }); } @@ -530,6 +603,12 @@ async function repairMissingPluginInstalls(params: { env, }); const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id)); + const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({ + cfg: params.cfg, + env, + snapshot, + configuredChannelIds: params.channelIds, + }); const bundledPluginsById = new Map( snapshot.plugins .filter((plugin) => plugin.origin === "bundled") @@ -569,6 +648,7 @@ async function repairMissingPluginInstalls(params: { env, configuredPluginIds: params.pluginIds, configuredChannelIds: params.channelIds, + configuredChannelOwnerPluginIds, blockedPluginIds: params.blockedPluginIds, }); for (const pluginId of updateDeferredPluginIds) { @@ -642,6 +722,7 @@ async function repairMissingPluginInstalls(params: { missingPluginIds, configuredPluginIds: params.pluginIds, configuredChannelIds: params.channelIds, + configuredChannelOwnerPluginIds, blockedPluginIds: deferredPluginIds.size > 0 ? new Set([...(params.blockedPluginIds ?? []), ...deferredPluginIds])