From cdaafe198cb7788389f7de99aef8632fab5fde33 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 1 Jul 2026 16:31:52 -0700 Subject: [PATCH] doctor: expose channel plugin blocker findings (#97496) --- .../shared/channel-plugin-blockers.test.ts | 11 ++++ .../doctor/shared/channel-plugin-blockers.ts | 24 ++++++- src/flows/doctor-health-contributions.test.ts | 63 ++++++++++++++++++- src/flows/doctor-health-contributions.ts | 12 ++++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor/shared/channel-plugin-blockers.test.ts b/src/commands/doctor/shared/channel-plugin-blockers.test.ts index ec24a45092ba..2aead91b0c8e 100644 --- a/src/commands/doctor/shared/channel-plugin-blockers.test.ts +++ b/src/commands/doctor/shared/channel-plugin-blockers.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import * as manifestRegistry from "../../../plugins/manifest-registry.js"; import { + channelPluginBlockerHitToHealthFinding, collectConfiguredChannelPluginBlockerWarnings, isWarningBlockedByChannelPlugin, scanConfiguredChannelPluginBlockers, @@ -63,6 +64,16 @@ describe("channel plugin blockers", () => { expect(collectConfiguredChannelPluginBlockerWarnings(hits)).toEqual([ '- 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.', ]); + expect(channelPluginBlockerHitToHealthFinding(hits[0])).toEqual({ + checkId: "core/doctor/channel-plugin-blockers", + severity: "warning", + message: + '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.', + path: "channels.discord", + target: "discord", + requirement: "missing explicit enablement", + fixHint: "Fix plugin enablement before relying on setup guidance for this channel.", + }); }); it("reports blockers for enabled-only channel intent", () => { diff --git a/src/commands/doctor/shared/channel-plugin-blockers.ts b/src/commands/doctor/shared/channel-plugin-blockers.ts index 2b310bb5a282..4121ef9da6a9 100644 --- a/src/commands/doctor/shared/channel-plugin-blockers.ts +++ b/src/commands/doctor/shared/channel-plugin-blockers.ts @@ -3,6 +3,7 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; import { listExplicitlyDisabledChannelIdsForConfig } from "../../../channels/config-presence.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { HealthFinding } from "../../../flows/health-checks.js"; import { hasExplicitChannelConfig, listExplicitConfiguredChannelIdsForConfig, @@ -20,7 +21,9 @@ import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js"; import { isSafeChannelEnvVarTriggerName } from "../../../secrets/channel-env-var-names.js"; -type ChannelPluginBlockerHit = { +const CHANNEL_PLUGIN_BLOCKERS_CHECK_ID = "core/doctor/channel-plugin-blockers"; + +export type ChannelPluginBlockerHit = { /** Normalized configured channel id whose backing plugin is unavailable. */ channelId: string; /** Plugin id that would provide the configured channel. */ @@ -359,6 +362,25 @@ export function collectConfiguredChannelPluginBlockerWarnings( ); } +function stripListMarker(message: string): string { + return message.startsWith("- ") ? message.slice(2) : message; +} + +/** Convert a configured channel plugin blocker into a structured Doctor finding. */ +export function channelPluginBlockerHitToHealthFinding( + hit: ChannelPluginBlockerHit, +): HealthFinding { + return { + checkId: CHANNEL_PLUGIN_BLOCKERS_CHECK_ID, + severity: "warning", + message: stripListMarker(collectConfiguredChannelPluginBlockerWarnings([hit])[0] ?? ""), + path: `channels.${hit.channelId}`, + target: hit.pluginId, + requirement: hit.reason, + fixHint: "Fix plugin enablement before relying on setup guidance for this channel.", + }; +} + /** Return true when a setup warning targets a channel already explained by plugin blockers. */ export function isWarningBlockedByChannelPlugin( warning: string, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index bdcdf626074f..a30833177917 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -77,6 +77,19 @@ const mocks = vi.hoisted(() => ({ noteWorkspaceStatus: vi.fn(), collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]), collectDevicePairingHealthFindings: vi.fn(async () => []), + scanConfiguredChannelPluginBlockers: vi.fn( + (): Array<{ channelId: string; pluginId: string; reason: string }> => [], + ), + channelPluginBlockerHitToHealthFinding: vi.fn( + (hit: { channelId: string; pluginId: string; reason: string }) => ({ + checkId: "core/doctor/channel-plugin-blockers", + severity: "warning" as const, + message: "channels." + hit.channelId + " blocked", + path: "channels." + hit.channelId, + target: hit.pluginId, + requirement: hit.reason, + }), + ), applyWizardMetadata: vi.fn((cfg: unknown) => cfg), logConfigUpdated: vi.fn(), isRecord: vi.fn( @@ -278,6 +291,11 @@ vi.mock("../commands/doctor-device-pairing.js", () => ({ noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../commands/doctor/shared/channel-plugin-blockers.js", () => ({ + scanConfiguredChannelPluginBlockers: mocks.scanConfiguredChannelPluginBlockers, + channelPluginBlockerHitToHealthFinding: mocks.channelPluginBlockerHitToHealthFinding, +})); + vi.mock("../commands/onboard-helpers.js", () => ({ applyWizardMetadata: mocks.applyWizardMetadata, randomToken: vi.fn(() => "generated-gateway-token"), @@ -452,6 +470,9 @@ describe("doctor health contributions", () => { mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]); mocks.collectDevicePairingHealthFindings.mockReset(); mocks.collectDevicePairingHealthFindings.mockResolvedValue([]); + mocks.scanConfiguredChannelPluginBlockers.mockReset(); + mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([]); + mocks.channelPluginBlockerHitToHealthFinding.mockClear(); }); afterEach(() => { @@ -1169,6 +1190,7 @@ describe("doctor health contributions", () => { expect(contributionIds).toContain("core/doctor/plugin-registry"); expect(contributionIds).toContain("core/doctor/configured-plugin-installs"); expect(contributionIds).toContain("core/doctor/device-pairing"); + expect(contributionIds).toContain("core/doctor/channel-plugin-blockers"); expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); }); @@ -1279,7 +1301,6 @@ describe("doctor health contributions", () => { runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, } as const; const checks = [devicePairingCheck!]; - await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ checksRun: 0, checksSkipped: 1, @@ -1298,6 +1319,46 @@ describe("doctor health contributions", () => { }); }); + it("keeps channel plugin blockers opt-in for default lint selection", async () => { + const contributionChecks = await resolveDoctorContributionHealthChecks(); + const blockerCheck = contributionChecks.find( + (check) => check.id === "core/doctor/channel-plugin-blockers", + ); + expect(blockerCheck).toMatchObject({ defaultEnabled: false }); + expect(blockerCheck).toBeDefined(); + mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([ + { channelId: "discord", pluginId: "discord", reason: "missing explicit enablement" }, + ]); + + const ctx = { + cfg: { channels: { discord: { enabled: true } } }, + mode: "lint", + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + } as const; + const checks = [blockerCheck!]; + + await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ + checksRun: 0, + checksSkipped: 1, + }); + expect(mocks.scanConfiguredChannelPluginBlockers).not.toHaveBeenCalled(); + + await expect( + runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/channel-plugin-blockers"] }), + ).resolves.toMatchObject({ + checksRun: 1, + checksSkipped: 0, + findings: [ + expect.objectContaining({ + checkId: "core/doctor/channel-plugin-blockers", + path: "channels.discord", + target: "discord", + }), + ], + }); + expect(mocks.scanConfiguredChannelPluginBlockers).toHaveBeenCalledWith(ctx.cfg, process.env); + }); + it("uses legacy run when a contribution also declares structured health", async () => { const legacyRun = vi.fn(); const healthChecks = { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 6c25a6fa1926..e449f50def42 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1638,6 +1638,18 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:startup-channel-maintenance", label: "Startup channel maintenance", + healthChecks: { + id: "core/doctor/channel-plugin-blockers", + description: "Configured channels must have loadable backing channel plugins.", + defaultEnabled: false, + async detect(ctx) { + const { channelPluginBlockerHitToHealthFinding, scanConfiguredChannelPluginBlockers } = + await import("../commands/doctor/shared/channel-plugin-blockers.js"); + return scanConfiguredChannelPluginBlockers(ctx.cfg, process.env).map( + channelPluginBlockerHitToHealthFinding, + ); + }, + }, run: runStartupChannelMaintenanceHealth, }), createDoctorHealthContribution({