Skip to content

Commit cdaafe1

Browse files
authored
doctor: expose channel plugin blocker findings (#97496)
1 parent f876955 commit cdaafe1

4 files changed

Lines changed: 108 additions & 2 deletions

File tree

src/commands/doctor/shared/channel-plugin-blockers.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
44
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
55
import {
6+
channelPluginBlockerHitToHealthFinding,
67
collectConfiguredChannelPluginBlockerWarnings,
78
isWarningBlockedByChannelPlugin,
89
scanConfiguredChannelPluginBlockers,
@@ -63,6 +64,16 @@ describe("channel plugin blockers", () => {
6364
expect(collectConfiguredChannelPluginBlockerWarnings(hits)).toEqual([
6465
'- channels.discord: channel is configured, but external plugin "discord" is installed without explicit trust. Add plugins.entries.discord.enabled=true. Fix plugin enablement before relying on setup guidance for this channel.',
6566
]);
67+
expect(channelPluginBlockerHitToHealthFinding(hits[0])).toEqual({
68+
checkId: "core/doctor/channel-plugin-blockers",
69+
severity: "warning",
70+
message:
71+
'channels.discord: channel is configured, but external plugin "discord" is installed without explicit trust. Add plugins.entries.discord.enabled=true. Fix plugin enablement before relying on setup guidance for this channel.',
72+
path: "channels.discord",
73+
target: "discord",
74+
requirement: "missing explicit enablement",
75+
fixHint: "Fix plugin enablement before relying on setup guidance for this channel.",
76+
});
6677
});
6778

6879
it("reports blockers for enabled-only channel intent", () => {

src/commands/doctor/shared/channel-plugin-blockers.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s
33
import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js";
44
import { listExplicitlyDisabledChannelIdsForConfig } from "../../../channels/config-presence.js";
55
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
6+
import type { HealthFinding } from "../../../flows/health-checks.js";
67
import {
78
hasExplicitChannelConfig,
89
listExplicitConfiguredChannelIdsForConfig,
@@ -20,7 +21,9 @@ import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js
2021
import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
2122
import { isSafeChannelEnvVarTriggerName } from "../../../secrets/channel-env-var-names.js";
2223

23-
type ChannelPluginBlockerHit = {
24+
const CHANNEL_PLUGIN_BLOCKERS_CHECK_ID = "core/doctor/channel-plugin-blockers";
25+
26+
export type ChannelPluginBlockerHit = {
2427
/** Normalized configured channel id whose backing plugin is unavailable. */
2528
channelId: string;
2629
/** Plugin id that would provide the configured channel. */
@@ -359,6 +362,25 @@ export function collectConfiguredChannelPluginBlockerWarnings(
359362
);
360363
}
361364

365+
function stripListMarker(message: string): string {
366+
return message.startsWith("- ") ? message.slice(2) : message;
367+
}
368+
369+
/** Convert a configured channel plugin blocker into a structured Doctor finding. */
370+
export function channelPluginBlockerHitToHealthFinding(
371+
hit: ChannelPluginBlockerHit,
372+
): HealthFinding {
373+
return {
374+
checkId: CHANNEL_PLUGIN_BLOCKERS_CHECK_ID,
375+
severity: "warning",
376+
message: stripListMarker(collectConfiguredChannelPluginBlockerWarnings([hit])[0] ?? ""),
377+
path: `channels.${hit.channelId}`,
378+
target: hit.pluginId,
379+
requirement: hit.reason,
380+
fixHint: "Fix plugin enablement before relying on setup guidance for this channel.",
381+
};
382+
}
383+
362384
/** Return true when a setup warning targets a channel already explained by plugin blockers. */
363385
export function isWarningBlockedByChannelPlugin(
364386
warning: string,

src/flows/doctor-health-contributions.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ const mocks = vi.hoisted(() => ({
7777
noteWorkspaceStatus: vi.fn(),
7878
collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]),
7979
collectDevicePairingHealthFindings: vi.fn(async () => []),
80+
scanConfiguredChannelPluginBlockers: vi.fn(
81+
(): Array<{ channelId: string; pluginId: string; reason: string }> => [],
82+
),
83+
channelPluginBlockerHitToHealthFinding: vi.fn(
84+
(hit: { channelId: string; pluginId: string; reason: string }) => ({
85+
checkId: "core/doctor/channel-plugin-blockers",
86+
severity: "warning" as const,
87+
message: "channels." + hit.channelId + " blocked",
88+
path: "channels." + hit.channelId,
89+
target: hit.pluginId,
90+
requirement: hit.reason,
91+
}),
92+
),
8093
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
8194
logConfigUpdated: vi.fn(),
8295
isRecord: vi.fn(
@@ -278,6 +291,11 @@ vi.mock("../commands/doctor-device-pairing.js", () => ({
278291
noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined),
279292
}));
280293

294+
vi.mock("../commands/doctor/shared/channel-plugin-blockers.js", () => ({
295+
scanConfiguredChannelPluginBlockers: mocks.scanConfiguredChannelPluginBlockers,
296+
channelPluginBlockerHitToHealthFinding: mocks.channelPluginBlockerHitToHealthFinding,
297+
}));
298+
281299
vi.mock("../commands/onboard-helpers.js", () => ({
282300
applyWizardMetadata: mocks.applyWizardMetadata,
283301
randomToken: vi.fn(() => "generated-gateway-token"),
@@ -452,6 +470,9 @@ describe("doctor health contributions", () => {
452470
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]);
453471
mocks.collectDevicePairingHealthFindings.mockReset();
454472
mocks.collectDevicePairingHealthFindings.mockResolvedValue([]);
473+
mocks.scanConfiguredChannelPluginBlockers.mockReset();
474+
mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([]);
475+
mocks.channelPluginBlockerHitToHealthFinding.mockClear();
455476
});
456477

457478
afterEach(() => {
@@ -1169,6 +1190,7 @@ describe("doctor health contributions", () => {
11691190
expect(contributionIds).toContain("core/doctor/plugin-registry");
11701191
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
11711192
expect(contributionIds).toContain("core/doctor/device-pairing");
1193+
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
11721194
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
11731195
});
11741196

@@ -1279,7 +1301,6 @@ describe("doctor health contributions", () => {
12791301
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
12801302
} as const;
12811303
const checks = [devicePairingCheck!];
1282-
12831304
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
12841305
checksRun: 0,
12851306
checksSkipped: 1,
@@ -1298,6 +1319,46 @@ describe("doctor health contributions", () => {
12981319
});
12991320
});
13001321

1322+
it("keeps channel plugin blockers opt-in for default lint selection", async () => {
1323+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1324+
const blockerCheck = contributionChecks.find(
1325+
(check) => check.id === "core/doctor/channel-plugin-blockers",
1326+
);
1327+
expect(blockerCheck).toMatchObject({ defaultEnabled: false });
1328+
expect(blockerCheck).toBeDefined();
1329+
mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([
1330+
{ channelId: "discord", pluginId: "discord", reason: "missing explicit enablement" },
1331+
]);
1332+
1333+
const ctx = {
1334+
cfg: { channels: { discord: { enabled: true } } },
1335+
mode: "lint",
1336+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1337+
} as const;
1338+
const checks = [blockerCheck!];
1339+
1340+
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
1341+
checksRun: 0,
1342+
checksSkipped: 1,
1343+
});
1344+
expect(mocks.scanConfiguredChannelPluginBlockers).not.toHaveBeenCalled();
1345+
1346+
await expect(
1347+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/channel-plugin-blockers"] }),
1348+
).resolves.toMatchObject({
1349+
checksRun: 1,
1350+
checksSkipped: 0,
1351+
findings: [
1352+
expect.objectContaining({
1353+
checkId: "core/doctor/channel-plugin-blockers",
1354+
path: "channels.discord",
1355+
target: "discord",
1356+
}),
1357+
],
1358+
});
1359+
expect(mocks.scanConfiguredChannelPluginBlockers).toHaveBeenCalledWith(ctx.cfg, process.env);
1360+
});
1361+
13011362
it("uses legacy run when a contribution also declares structured health", async () => {
13021363
const legacyRun = vi.fn();
13031364
const healthChecks = {

src/flows/doctor-health-contributions.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,6 +1638,18 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
16381638
createDoctorHealthContribution({
16391639
id: "doctor:startup-channel-maintenance",
16401640
label: "Startup channel maintenance",
1641+
healthChecks: {
1642+
id: "core/doctor/channel-plugin-blockers",
1643+
description: "Configured channels must have loadable backing channel plugins.",
1644+
defaultEnabled: false,
1645+
async detect(ctx) {
1646+
const { channelPluginBlockerHitToHealthFinding, scanConfiguredChannelPluginBlockers } =
1647+
await import("../commands/doctor/shared/channel-plugin-blockers.js");
1648+
return scanConfiguredChannelPluginBlockers(ctx.cfg, process.env).map(
1649+
channelPluginBlockerHitToHealthFinding,
1650+
);
1651+
},
1652+
},
16411653
run: runStartupChannelMaintenanceHealth,
16421654
}),
16431655
createDoctorHealthContribution({

0 commit comments

Comments
 (0)