doctor: expose channel plugin blocker findings (#97496)

This commit is contained in:
Gio Della-Libera
2026-07-01 16:31:52 -07:00
committed by GitHub
parent f8769559ae
commit cdaafe198c
4 changed files with 108 additions and 2 deletions
@@ -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", () => {
@@ -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,
+62 -1
View File
@@ -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 = {
+12
View File
@@ -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({