From ae142cad7c75fed7ef5f5c5f293373e493ce4bef Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 16:31:41 -0700 Subject: [PATCH] fix(doctor): repair allow-only official plugins --- CHANGELOG.md | 1 + ...release-configured-plugin-installs.test.ts | 59 +++++++++++++++++++ .../release-configured-plugin-installs.ts | 25 ++++++++ 3 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4465b3ef515..35096e71ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. - Mattermost: clarify that the model picker only changes the session model and that runtime switches require `/oc_model --runtime `. Thanks @vincentkoc. - Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. +- Doctor/plugins: include `plugins.allow`-only official plugin ids in the release configured-plugin repair set, so `doctor --fix` installs official external plugins that are configured but not yet loaded instead of removing them as stale allow entries. Fixes #77155. Thanks @hclsys. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index cb157d512aa..eb98948ce7d 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ detectPluginAutoEnableCandidates: vi.fn(), + getOfficialExternalPluginCatalogEntry: vi.fn(), repairMissingPluginInstallsForIds: vi.fn(), resolveProviderInstallCatalogEntries: vi.fn(), })); @@ -14,6 +15,14 @@ vi.mock("../../../plugins/provider-install-catalog.js", () => ({ resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries, })); +vi.mock(import("../../../plugins/official-external-plugin-catalog.js"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOfficialExternalPluginCatalogEntry: mocks.getOfficialExternalPluginCatalogEntry, + }; +}); + vi.mock("./missing-configured-plugin-install.js", () => ({ repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds, })); @@ -22,6 +31,7 @@ describe("configured plugin install release step", () => { beforeEach(() => { vi.clearAllMocks(); mocks.detectPluginAutoEnableCandidates.mockReturnValue([]); + mocks.getOfficialExternalPluginCatalogEntry.mockReturnValue(undefined); mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]); mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ changes: [], @@ -372,4 +382,53 @@ describe("configured plugin install release step", () => { touchedConfig: false, }); }); + + it("includes allow-only official plugin ids in the repair set", async () => { + mocks.getOfficialExternalPluginCatalogEntry.mockImplementation((pluginId: string) => { + if (pluginId === "lobster") { + return { name: "@openclaw/lobster" }; + } + return undefined; + }); + + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + const result = collectReleaseConfiguredPluginIds({ + cfg: { + plugins: { + allow: ["lobster", "unofficial-custom"], + }, + }, + env: {}, + }); + + expect(result.pluginIds).toEqual(["lobster"]); + expect(result.channelIds).toEqual([]); + }); + + it("skips allow-only plugin ids that already have material plugin entries", async () => { + mocks.getOfficialExternalPluginCatalogEntry.mockImplementation((pluginId: string) => { + if (pluginId === "lobster") { + return { name: "@openclaw/lobster" }; + } + return undefined; + }); + + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + const result = collectReleaseConfiguredPluginIds({ + cfg: { + plugins: { + allow: ["lobster"], + entries: { + lobster: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.pluginIds).toEqual(["lobster"]); + expect(mocks.getOfficialExternalPluginCatalogEntry).not.toHaveBeenCalledWith("lobster"); + }); }); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 6d3ddb0133f..69a7f2138f5 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -7,6 +7,7 @@ import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-en import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { compareOpenClawVersions } from "../../../config/version.js"; import { isTruthyEnvValue } from "../../../infra/env.js"; +import { getOfficialExternalPluginCatalogEntry } from "../../../plugins/official-external-plugin-catalog.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; import { VERSION } from "../../../version.js"; @@ -214,6 +215,27 @@ function collectAcpRuntimePluginIds(cfg: OpenClawConfig): string[] { return ["acpx"]; } +function collectAllowOnlyOfficialPluginIds(cfg: OpenClawConfig): string[] { + const allow = cfg.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return []; + } + const materialEntryIds = new Set( + collectMaterialPluginEntryIds(cfg).map((id) => id.toLowerCase()), + ); + const ids: string[] = []; + for (const rawPluginId of allow) { + const pluginId = normalizeId(rawPluginId); + if (!pluginId || materialEntryIds.has(pluginId.toLowerCase())) { + continue; + } + if (getOfficialExternalPluginCatalogEntry(pluginId)) { + ids.push(pluginId); + } + } + return ids; +} + function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set, pluginId: string): void { const normalized = pluginId.trim(); if (!normalized || isDenied(cfg, normalized) || isDisabled(cfg, normalized)) { @@ -274,6 +296,9 @@ export function collectReleaseConfiguredPluginIds(params: { for (const pluginId of collectAcpRuntimePluginIds(params.cfg)) { addEligiblePluginId(params.cfg, pluginIds, pluginId); } + for (const pluginId of collectAllowOnlyOfficialPluginIds(params.cfg)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } for (const channelId of collectConfiguredChannelIds(params.cfg, env)) { if ( !isChannelDisabled(params.cfg, channelId) &&