Skip to content

Commit cae3be1

Browse files
committed
fix(doctor): repair allow-only official plugins
1 parent ab03267 commit cae3be1

3 files changed

Lines changed: 85 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
6969
- 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.
7070
- Mattermost: clarify that the model picker only changes the session model and that runtime switches require `/oc_model <provider/model> --runtime <runtime>`. Thanks @vincentkoc.
7171
- 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.
72+
- 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.
7273
- 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.
7374
- 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.
7475
- 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.

src/commands/doctor/shared/release-configured-plugin-installs.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mocks = vi.hoisted(() => ({
44
detectPluginAutoEnableCandidates: vi.fn(),
5+
getOfficialExternalPluginCatalogEntry: vi.fn(),
56
repairMissingPluginInstallsForIds: vi.fn(),
67
resolveProviderInstallCatalogEntries: vi.fn(),
78
}));
@@ -14,6 +15,14 @@ vi.mock("../../../plugins/provider-install-catalog.js", () => ({
1415
resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries,
1516
}));
1617

18+
vi.mock(import("../../../plugins/official-external-plugin-catalog.js"), async (importOriginal) => {
19+
const actual = await importOriginal();
20+
return {
21+
...actual,
22+
getOfficialExternalPluginCatalogEntry: mocks.getOfficialExternalPluginCatalogEntry,
23+
};
24+
});
25+
1726
vi.mock("./missing-configured-plugin-install.js", () => ({
1827
repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds,
1928
}));
@@ -22,6 +31,7 @@ describe("configured plugin install release step", () => {
2231
beforeEach(() => {
2332
vi.clearAllMocks();
2433
mocks.detectPluginAutoEnableCandidates.mockReturnValue([]);
34+
mocks.getOfficialExternalPluginCatalogEntry.mockReturnValue(undefined);
2535
mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]);
2636
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
2737
changes: [],
@@ -372,4 +382,53 @@ describe("configured plugin install release step", () => {
372382
touchedConfig: false,
373383
});
374384
});
385+
386+
it("includes allow-only official plugin ids in the repair set", async () => {
387+
mocks.getOfficialExternalPluginCatalogEntry.mockImplementation((pluginId: string) => {
388+
if (pluginId === "lobster") {
389+
return { name: "@openclaw/lobster" };
390+
}
391+
return undefined;
392+
});
393+
394+
const { collectReleaseConfiguredPluginIds } =
395+
await import("./release-configured-plugin-installs.js");
396+
const result = collectReleaseConfiguredPluginIds({
397+
cfg: {
398+
plugins: {
399+
allow: ["lobster", "unofficial-custom"],
400+
},
401+
},
402+
env: {},
403+
});
404+
405+
expect(result.pluginIds).toEqual(["lobster"]);
406+
expect(result.channelIds).toEqual([]);
407+
});
408+
409+
it("skips allow-only plugin ids that already have material plugin entries", async () => {
410+
mocks.getOfficialExternalPluginCatalogEntry.mockImplementation((pluginId: string) => {
411+
if (pluginId === "lobster") {
412+
return { name: "@openclaw/lobster" };
413+
}
414+
return undefined;
415+
});
416+
417+
const { collectReleaseConfiguredPluginIds } =
418+
await import("./release-configured-plugin-installs.js");
419+
const result = collectReleaseConfiguredPluginIds({
420+
cfg: {
421+
plugins: {
422+
allow: ["lobster"],
423+
entries: {
424+
lobster: { enabled: true },
425+
},
426+
},
427+
},
428+
env: {},
429+
});
430+
431+
expect(result.pluginIds).toEqual(["lobster"]);
432+
expect(mocks.getOfficialExternalPluginCatalogEntry).not.toHaveBeenCalledWith("lobster");
433+
});
375434
});

src/commands/doctor/shared/release-configured-plugin-installs.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-en
77
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
88
import { compareOpenClawVersions } from "../../../config/version.js";
99
import { isTruthyEnvValue } from "../../../infra/env.js";
10+
import { getOfficialExternalPluginCatalogEntry } from "../../../plugins/official-external-plugin-catalog.js";
1011
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
1112
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
1213
import { VERSION } from "../../../version.js";
@@ -214,6 +215,27 @@ function collectAcpRuntimePluginIds(cfg: OpenClawConfig): string[] {
214215
return ["acpx"];
215216
}
216217

218+
function collectAllowOnlyOfficialPluginIds(cfg: OpenClawConfig): string[] {
219+
const allow = cfg.plugins?.allow;
220+
if (!Array.isArray(allow) || allow.length === 0) {
221+
return [];
222+
}
223+
const materialEntryIds = new Set(
224+
collectMaterialPluginEntryIds(cfg).map((id) => id.toLowerCase()),
225+
);
226+
const ids: string[] = [];
227+
for (const rawPluginId of allow) {
228+
const pluginId = normalizeId(rawPluginId);
229+
if (!pluginId || materialEntryIds.has(pluginId.toLowerCase())) {
230+
continue;
231+
}
232+
if (getOfficialExternalPluginCatalogEntry(pluginId)) {
233+
ids.push(pluginId);
234+
}
235+
}
236+
return ids;
237+
}
238+
217239
function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set<string>, pluginId: string): void {
218240
const normalized = pluginId.trim();
219241
if (!normalized || isDenied(cfg, normalized) || isDisabled(cfg, normalized)) {
@@ -274,6 +296,9 @@ export function collectReleaseConfiguredPluginIds(params: {
274296
for (const pluginId of collectAcpRuntimePluginIds(params.cfg)) {
275297
addEligiblePluginId(params.cfg, pluginIds, pluginId);
276298
}
299+
for (const pluginId of collectAllowOnlyOfficialPluginIds(params.cfg)) {
300+
addEligiblePluginId(params.cfg, pluginIds, pluginId);
301+
}
277302
for (const channelId of collectConfiguredChannelIds(params.cfg, env)) {
278303
if (
279304
!isChannelDisabled(params.cfg, channelId) &&

0 commit comments

Comments
 (0)