fix(doctor): repair allow-only official plugins

This commit is contained in:
Vincent Koc
2026-05-04 16:31:41 -07:00
committed by GitHub
parent d0c7f91ed1
commit ae142cad7c
3 changed files with 85 additions and 0 deletions

View File

@@ -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 <provider/model> --runtime <runtime>`. Thanks @vincentkoc.
- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `<provider>: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.

View File

@@ -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");
});
});

View File

@@ -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<string>, 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) &&