From e56fd1dc0466840241897d88411f38e9714f54f5 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sat, 20 Jun 2026 10:59:31 -0700 Subject: [PATCH] Keep core doctor health in contribution order (#86627) Merged via squash. Prepared head SHA: e0955797c1143134e0c96362539d200af590ea16 Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Reviewed-by: @giodl73-repo --- docs/cli/doctor.md | 10 +- scripts/plugin-sdk-surface-report.mjs | 3 +- src/auto-reply/heartbeat.ts | 6 +- .../doctor-heartbeat-template-repair.test.ts | 13 +- src/commands/doctor-lint.test.ts | 130 +++- src/commands/doctor-lint.ts | 12 +- src/flows/doctor-core-checks.test.ts | 66 +- src/flows/doctor-core-checks.ts | 1 + src/flows/doctor-health-contributions.test.ts | 692 +++++++++++++++++- src/flows/doctor-health-contributions.ts | 240 +++++- .../doctor-health-conversion-plan.test.ts | 39 - src/flows/doctor-health-conversion-plan.ts | 291 -------- src/flows/doctor-repair-flow.ts | 9 +- src/flows/health-check-registry.ts | 14 + 14 files changed, 1051 insertions(+), 475 deletions(-) delete mode 100644 src/flows/doctor-health-conversion-plan.test.ts delete mode 100644 src/flows/doctor-health-conversion-plan.ts diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 43ba30d7d72f..aeabd4f33929 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -172,10 +172,12 @@ A finding includes: | `ocPath` | Precise `oc://` address when a check can point to one. | | `fixHint` | Suggested operator action or repair summary. | -This release registers the modernized core doctor checks on the structured -health path. The `openclaw/plugin-sdk/health` subpath exposes the same -contract for bundled follow-up consumers, but plugin-backed checks only run -after their owning package registers them in the active command path. +Modernized core doctor checks stay attached to the ordered doctor contribution +that owns their human `doctor` / `doctor --fix` behavior. The shared structured +health registry is the extension point: bundled and plugin-backed checks run +after core doctor checks once their owning package registers them in the active +command path. The `openclaw/plugin-sdk/health` subpath exposes the same +contract for those extension consumers. ## Check Selection diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 8c41418898a7..a2dcd6d75f5f 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -64,6 +64,7 @@ function readEntrypointBudgetEnv(name, fallback) { const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ core: 2, + health: 1, lmstudio: 1, "provider-setup": 1, "self-hosted-provider-setup": 14, @@ -165,7 +166,7 @@ try { publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5171), publicDeprecatedExports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", - 3244, + 3245, ), publicWildcardReexports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 9f9e08e5e2f2..18cca102173a 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -96,9 +96,9 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) { continue; } - // Ignore markdown fence markers that were added for doc rendering but do - // not carry task semantics in the workspace template body. - if (/^```[A-Za-z0-9_-]*$/.test(trimmed)) { + // Ignore markdown fence markers and HTML comments that only document the + // workspace template; neither carries heartbeat task semantics. + if (/^```[A-Za-z0-9_-]*$/.test(trimmed) || /^$/.test(trimmed)) { continue; } // Found a non-empty, non-comment line - there's actionable content diff --git a/src/commands/doctor-heartbeat-template-repair.test.ts b/src/commands/doctor-heartbeat-template-repair.test.ts index 9e22ca5cde9d..9333ada806a0 100644 --- a/src/commands/doctor-heartbeat-template-repair.test.ts +++ b/src/commands/doctor-heartbeat-template-repair.test.ts @@ -1,4 +1,3 @@ -// Doctor heartbeat template repair tests cover migration and repair of heartbeat prompt templates. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -207,15 +206,11 @@ Add short tasks below the comments only when you want the agent to check somethi shouldRepair: true, }); - await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe( - `${[ - "", - "", - "# Keep this file empty (or with only comments) to skip heartbeat API calls.", - "", - "# Add tasks below when you want the agent to check something periodically.", - ].join("\n")}\n`, + const cleanTemplate = await fs.readFile( + path.resolve("src", "agents", "templates", "HEARTBEAT.md"), + "utf-8", ); + await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe(cleanTemplate); expect(mocks.note).toHaveBeenCalledWith( expect.stringContaining("clean heartbeat template"), "Doctor changes", diff --git a/src/commands/doctor-lint.test.ts b/src/commands/doctor-lint.test.ts index 5d10b6f58992..0b454bd33998 100644 --- a/src/commands/doctor-lint.test.ts +++ b/src/commands/doctor-lint.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resetCoreHealthChecksForTest } from "../flows/doctor-core-checks.js"; -import { clearHealthChecksForTest } from "../flows/health-check-registry.js"; +import { clearHealthChecksForTest, registerHealthCheck } from "../flows/health-check-registry.js"; import { runDoctorLintCli } from "./doctor-lint.js"; const mocks = vi.hoisted(() => ({ @@ -192,6 +192,134 @@ describe("runDoctorLintCli", () => { } }); + it("runs core contribution checks plus registered extension checks", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + path: "/tmp/openclaw.json", + }); + registerHealthCheck({ + id: "plugin/example/lint", + kind: "plugin", + description: "example plugin lint check", + async detect() { + return [ + { + checkId: "plugin/example/lint", + severity: "info", + message: "plugin finding", + }, + ]; + }, + }); + + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + const exitCode = await runDoctorLintCli(runtime, { + json: true, + onlyIds: ["core/doctor/final-config-validation", "plugin/example/lint"], + }); + + expect(exitCode).toBe(1); + const payload = JSON.parse(String(stdout.mock.calls.at(-1)?.[0])); + expect(payload.checksRun).toBe(2); + expect(payload.findings).toEqual([ + { + checkId: "plugin/example/lint", + severity: "info", + message: "plugin finding", + }, + ]); + } finally { + stdout.mockRestore(); + } + }); + + it("rejects extension checks that reuse ordered core check ids", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + path: "/tmp/openclaw.json", + }); + registerHealthCheck({ + id: "core/doctor/final-config-validation", + kind: "plugin", + description: "colliding plugin lint check", + async detect() { + return []; + }, + }); + + await expect(runDoctorLintCli(runtime, { json: true })).rejects.toThrow( + "health check already registered: core/doctor/final-config-validation", + ); + }); + + it("rejects registered core-kind checks that reuse ordered core check ids", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + path: "/tmp/openclaw.json", + }); + registerHealthCheck({ + id: "core/doctor/final-config-validation", + kind: "core", + description: "colliding core-kind lint check", + async detect() { + return []; + }, + }); + + await expect(runDoctorLintCli(runtime, { json: true })).rejects.toThrow( + "health check already registered: core/doctor/final-config-validation", + ); + }); + + it("rejects extension checks that claim unused reserved core doctor ids", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + path: "/tmp/openclaw.json", + }); + registerHealthCheck({ + id: "core/doctor/not-yet-owned", + kind: "plugin", + description: "reserved plugin lint check", + async detect() { + return []; + }, + }); + + await expect(runDoctorLintCli(runtime, { json: true })).rejects.toThrow( + "health check already registered: core/doctor/not-yet-owned", + ); + }); + + it("rejects registered core-kind checks that claim unused reserved core doctor ids", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + path: "/tmp/openclaw.json", + }); + registerHealthCheck({ + id: "core/doctor/not-yet-owned", + kind: "core", + description: "reserved core-kind lint check", + async detect() { + return []; + }, + }); + + await expect(runDoctorLintCli(runtime, { json: true })).rejects.toThrow( + "health check already registered: core/doctor/not-yet-owned", + ); + }); + it("rejects invalid severity thresholds", async () => { await expect(runDoctorLintCli(runtime, { severityMin: "warnng" })).rejects.toThrow( "Invalid --severity-min value", diff --git a/src/commands/doctor-lint.ts b/src/commands/doctor-lint.ts index 41ab2a0cb647..46d727447880 100644 --- a/src/commands/doctor-lint.ts +++ b/src/commands/doctor-lint.ts @@ -2,15 +2,14 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { readConfigFileSnapshot } from "../config/config.js"; import { registerBundledHealthChecks } from "../flows/bundled-health-checks.js"; -import { - configValidationIssuesToHealthFindings, - registerCoreHealthChecks, -} from "../flows/doctor-core-checks.js"; +import { configValidationIssuesToHealthFindings } from "../flows/doctor-core-checks.js"; +import { resolveDoctorContributionHealthChecks } from "../flows/doctor-health-contributions.js"; import { exitCodeFromFindings, runDoctorLintChecks, type DoctorLintRunOptions, } from "../flows/doctor-lint-flow.js"; +import { listExtensionHealthChecksForDoctor } from "../flows/health-check-registry.js"; import { healthFindingMeetsSeverity, parseHealthFindingSeverity, @@ -44,8 +43,6 @@ export async function runDoctorLintCli( runtime: RuntimeEnv, opts: DoctorLintCliOptions, ): Promise { - registerCoreHealthChecks(); - const sevMin = opts.severityMin === undefined ? "info" : parseHealthFindingSeverity(opts.severityMin); if (sevMin === null) { @@ -81,8 +78,11 @@ export async function runDoctorLintCli( ...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}), }; registerBundledHealthChecks({ cfg: snapshot.config, cwd: ctx.cwd }); + const coreChecks = await resolveDoctorContributionHealthChecks(); + const extensionChecks = listExtensionHealthChecksForDoctor(coreChecks); const runOpts: DoctorLintRunOptions = { + checks: [...coreChecks, ...extensionChecks], ...(opts.skipIds && opts.skipIds.length > 0 ? { skipIds: opts.skipIds } : {}), ...(opts.onlyIds && opts.onlyIds.length > 0 ? { onlyIds: opts.onlyIds } : {}), }; diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 62ea99d6d047..49dab0045cfb 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -9,16 +9,10 @@ import { withEnvAsync } from "../test-utils/env.js"; import { CORE_HEALTH_CHECKS, createCoreHealthChecks, - type CoreHealthCheckDeps, - registerCoreHealthChecks, resetCoreHealthChecksForTest, + type CoreHealthCheckDeps, } from "./doctor-core-checks.js"; -import { doctorHealthConversionRules } from "./doctor-health-conversion-plan.js"; -import { - clearHealthChecksForTest, - listHealthChecks, - registerHealthCheck, -} from "./health-check-registry.js"; +import { clearHealthChecksForTest } from "./health-check-registry.js"; import type { HealthCheck, HealthFinding } from "./health-checks.js"; const mocks = vi.hoisted(() => ({ @@ -98,7 +92,7 @@ function getCheck(checks: readonly HealthCheck[], id: string): HealthCheck { return check; } -describe("registerCoreHealthChecks", () => { +describe("CORE_HEALTH_CHECKS", () => { let tmp: string | undefined; let hooksModelCatalogCase: { calls: unknown[][]; @@ -132,8 +126,6 @@ describe("registerCoreHealthChecks", () => { }); beforeEach(() => { - clearHealthChecksForTest(); - resetCoreHealthChecksForTest(); mocks.loadModelCatalog.mockClear(); mocks.loadModelCatalog.mockResolvedValue([]); tmp = undefined; @@ -145,57 +137,7 @@ describe("registerCoreHealthChecks", () => { } }); - it("registers the built-in health checks once", () => { - registerCoreHealthChecks(); - registerCoreHealthChecks(); - - expect(listHealthChecks().map((check) => check.id)).toEqual( - CORE_HEALTH_CHECKS.map((check) => check.id), - ); - }); - - it("can retry after a duplicate registration failure is cleared", () => { - registerHealthCheck({ - id: "core/doctor/gateway-config", - kind: "core", - description: "duplicate", - async detect() { - return []; - }, - }); - - expect(() => registerCoreHealthChecks()).toThrow("health check already registered"); - - clearHealthChecksForTest(); - registerCoreHealthChecks(); - - expect(listHealthChecks()).toHaveLength(CORE_HEALTH_CHECKS.length); - }); - - it("registers only implemented core health targets from the doctor conversion inventory", () => { - registerCoreHealthChecks(); - - const registeredIds = new Set(listHealthChecks().map((check) => check.id)); - const coreTargets = new Set( - doctorHealthConversionRules.flatMap((rule) => - rule.target.filter((target) => target.startsWith("core/doctor/")), - ), - ); - const plannedOnlyTargets = [ - "core/doctor/auth-profiles/keychain", - "core/doctor/session-locks", - "core/doctor/gateway-daemon", - ]; - - for (const id of CORE_HEALTH_CHECKS.map((check) => check.id)) { - if (id === "core/doctor/browser-clawd-profile-residue") { - continue; - } - expect(coreTargets.has(id)).toBe(true); - } - for (const id of plannedOnlyTargets) { - expect(registeredIds.has(id)).toBe(false); - } + it("does not include placeholder health registry entries", () => { expect( CORE_HEALTH_CHECKS.some((check) => check.description.endsWith("represented in the health registry."), diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index a4237ce60a2d..7a7f2b9ed134 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -967,6 +967,7 @@ function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly Heal let registered = false; +/** @deprecated Core doctor flows use ordered doctor contributions; keep this only for SDK compatibility. */ export function registerCoreHealthChecks(): void { if (registered) { return; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 03c192b83f74..e9d097b8a83f 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -2,18 +2,58 @@ import fs from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DoctorPrompter } from "../commands/doctor-prompter.js"; +import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js"; import { + createDoctorHealthContribution, + resolveDoctorContributionHealthChecks, resolveDoctorHealthContributions, shouldSkipLegacyUpdateDoctorConfigWrite, } from "./doctor-health-contributions.js"; +import type { HealthCheck } from "./health-checks.js"; const mocks = vi.hoisted(() => ({ maybeRunConfiguredPluginInstallReleaseStep: vi.fn(), - registerCoreHealthChecks: vi.fn(), registerBundledHealthChecks: vi.fn(), runDoctorHealthRepairs: vi.fn(), + maybeRepairLegacyFlatAuthProfileStores: vi.fn().mockResolvedValue(undefined), + maybeRepairCanonicalApiKeyFieldAlias: vi.fn().mockResolvedValue(undefined), + maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined), + maybeRepairLegacyOAuthProfileIds: vi.fn(async (cfg: unknown) => cfg), + maybeRepairLegacyOAuthSidecarProfiles: vi.fn().mockResolvedValue(undefined), + noteAuthProfileHealth: vi.fn().mockResolvedValue(undefined), + noteLegacyCodexProviderOverride: vi.fn(), + buildGatewayConnectionDetails: vi.fn(() => ({ message: "gateway details" })), + resolveSecretInputRef: vi.fn((params: { value?: unknown }) => ({ + ref: + params.value === "exec-token" + ? { source: "exec", command: "printf token", cache: false } + : undefined, + })), + resolveGatewayAuth: vi.fn(() => ({ mode: "token", token: undefined })), + resolveGatewayAuthToken: vi.fn(async () => ({ + source: "unavailable", + unresolvedRefReason: "exec provider failed", + })), + getSkippedExecRefStaticError: vi.fn(() => undefined), + maybeRepairGatewayServiceConfig: vi.fn().mockResolvedValue(undefined), + maybeScanExtraGatewayServices: vi.fn().mockResolvedValue(undefined), + noteMacLaunchAgentOverrides: vi.fn(), + noteMacLaunchctlGatewayEnvOverrides: vi.fn(), + noteMacStaleOpenClawUpdateLaunchdJobs: vi.fn(), + gatewaySecretInputPathCanWin: vi.fn(), + readGatewaySecretInputValue: vi.fn((..._args: unknown[]) => undefined as string | undefined), + checkGatewayHealth: vi.fn(async () => ({ + authenticated: true, + healthOk: true, + status: { ok: true }, + })), + probeGatewayMemoryStatus: vi.fn(async () => ({ checked: true, ready: true, skipped: false })), listHealthChecks: vi.fn(), getHealthCheck: vi.fn(), + registerHealthCheck: vi.fn(), + noteChromeMcpBrowserReadiness: vi.fn(), + detectLegacyClawdBrowserProfileResidue: vi.fn(), + maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(), resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"), resolveDefaultAgentId: vi.fn(() => "default"), note: vi.fn(), @@ -28,8 +68,6 @@ const mocks = vi.hoisted(() => ({ config: {}, issues: [], }), - checkGatewayHealth: vi.fn(), - probeGatewayMemoryStatus: vi.fn(), gatherDaemonStatus: vi.fn(), noteWorkspaceStatus: vi.fn(), applyWizardMetadata: vi.fn((cfg: unknown) => cfg), @@ -48,10 +86,6 @@ vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () => maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep, })); -vi.mock("./doctor-core-checks.js", () => ({ - registerCoreHealthChecks: mocks.registerCoreHealthChecks, -})); - vi.mock("./bundled-health-checks.js", () => ({ registerBundledHealthChecks: mocks.registerBundledHealthChecks, })); @@ -60,9 +94,118 @@ vi.mock("./doctor-repair-flow.js", () => ({ runDoctorHealthRepairs: mocks.runDoctorHealthRepairs, })); -vi.mock("./health-check-registry.js", () => ({ - listHealthChecks: mocks.listHealthChecks, - getHealthCheck: mocks.getHealthCheck, +vi.mock("../config/types.secrets.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSecretInputRef: mocks.resolveSecretInputRef, + }; +}); + +vi.mock("../gateway/auth.js", () => ({ + resolveGatewayAuth: mocks.resolveGatewayAuth, +})); + +vi.mock("../gateway/auth-token-resolution.js", () => ({ + resolveGatewayAuthToken: mocks.resolveGatewayAuthToken, +})); + +vi.mock("../secrets/exec-resolution-policy.js", () => ({ + getSkippedExecRefStaticError: mocks.getSkippedExecRefStaticError, +})); + +vi.mock("../commands/doctor-gateway-services.js", () => ({ + maybeRepairGatewayServiceConfig: mocks.maybeRepairGatewayServiceConfig, + maybeScanExtraGatewayServices: mocks.maybeScanExtraGatewayServices, +})); + +vi.mock("../commands/doctor-auth-flat-profiles.js", () => ({ + maybeRepairLegacyFlatAuthProfileStores: mocks.maybeRepairLegacyFlatAuthProfileStores, + maybeRepairCanonicalApiKeyFieldAlias: mocks.maybeRepairCanonicalApiKeyFieldAlias, +})); + +vi.mock("../commands/doctor-gateway-daemon-flow.js", () => ({ + maybeRepairGatewayDaemon: mocks.maybeRepairGatewayDaemon, +})); + +vi.mock("../commands/doctor-auth-legacy-oauth.js", () => ({ + maybeRepairLegacyOAuthProfileIds: mocks.maybeRepairLegacyOAuthProfileIds, +})); + +vi.mock("../commands/doctor-auth-oauth-sidecar.js", () => ({ + maybeRepairLegacyOAuthSidecarProfiles: mocks.maybeRepairLegacyOAuthSidecarProfiles, +})); + +vi.mock("../commands/doctor-auth.js", () => ({ + noteAuthProfileHealth: mocks.noteAuthProfileHealth, + noteLegacyCodexProviderOverride: mocks.noteLegacyCodexProviderOverride, +})); + +vi.mock("../gateway/call.js", () => ({ + buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, +})); + +vi.mock("../commands/doctor-platform-notes.js", () => ({ + noteMacLaunchAgentOverrides: mocks.noteMacLaunchAgentOverrides, + noteMacLaunchctlGatewayEnvOverrides: mocks.noteMacLaunchctlGatewayEnvOverrides, + noteMacStaleOpenClawUpdateLaunchdJobs: mocks.noteMacStaleOpenClawUpdateLaunchdJobs, +})); + +vi.mock("../gateway/credentials-secret-inputs.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + gatewaySecretInputPathCanWin: ( + ...args: Parameters + ) => + mocks.gatewaySecretInputPathCanWin.getMockImplementation() + ? mocks.gatewaySecretInputPathCanWin(...args) + : actual.gatewaySecretInputPathCanWin(...args), + }; +}); + +vi.mock("../gateway/secret-input-paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readGatewaySecretInputValue: (...args: Parameters) => + mocks.readGatewaySecretInputValue.getMockImplementation() + ? mocks.readGatewaySecretInputValue(...args) + : actual.readGatewaySecretInputValue(...args), + }; +}); + +vi.mock("../commands/doctor-gateway-health.js", () => ({ + checkGatewayHealth: mocks.checkGatewayHealth, + probeGatewayMemoryStatus: mocks.probeGatewayMemoryStatus, +})); + +vi.mock("./health-check-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listHealthChecks: mocks.listHealthChecks, + listExtensionHealthChecksForDoctor: ( + coreChecks: Parameters[0], + ) => { + const coreIds = new Set(coreChecks.map((check) => check.id)); + const registeredChecks = mocks.listHealthChecks() as readonly HealthCheck[]; + for (const check of registeredChecks) { + if (check.id.startsWith("core/doctor/") || coreIds.has(check.id)) { + throw new actual.HealthCheckRegistrationError(check.id); + } + } + return registeredChecks.filter((check) => check.kind !== "core"); + }, + getHealthCheck: mocks.getHealthCheck, + registerHealthCheck: mocks.registerHealthCheck, + }; +}); + +vi.mock("../commands/doctor-browser.js", () => ({ + noteChromeMcpBrowserReadiness: mocks.noteChromeMcpBrowserReadiness, + detectLegacyClawdBrowserProfileResidue: mocks.detectLegacyClawdBrowserProfileResidue, + maybeArchiveLegacyClawdBrowserProfileResidue: mocks.maybeArchiveLegacyClawdBrowserProfileResidue, })); vi.mock("../agents/agent-scope.js", () => ({ @@ -87,6 +230,8 @@ vi.mock("../agents/model-selection.js", () => ({ vi.mock("../version.js", async () => ({ ...(await vi.importActual("../version.js")), VERSION: "2026.5.2-test", + resolveCompatibilityHostVersion: vi.fn(() => "2026.5.2-test"), + resolveIsNixMode: vi.fn(() => false), })); vi.mock("../config/config.js", () => ({ @@ -110,17 +255,23 @@ vi.mock("../commands/doctor-workspace-status.js", () => ({ vi.mock("../commands/onboard-helpers.js", () => ({ applyWizardMetadata: mocks.applyWizardMetadata, + randomToken: vi.fn(() => "generated-gateway-token"), })); vi.mock("../config/logging.js", () => ({ logConfigUpdated: mocks.logConfigUpdated, })); -vi.mock("../utils.js", async (importOriginal) => ({ - ...(await importOriginal()), - isRecord: mocks.isRecord, - shortenHomePath: mocks.shortenHomePath, -})); +vi.mock("../utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isRecord: mocks.isRecord, + resolveConfigDir: vi.fn(() => "/tmp/openclaw-config"), + resolveUserPath: vi.fn((value: string) => value), + shortenHomePath: mocks.shortenHomePath, + }; +}); vi.mock("../cli/command-format.js", () => ({ formatCliCommand: mocks.formatCliCommand, @@ -156,9 +307,56 @@ function buildDoctorPrompter(shouldRepair: boolean): DoctorPrompter { describe("doctor health contributions", () => { beforeEach(() => { mocks.maybeRunConfiguredPluginInstallReleaseStep.mockReset(); - mocks.registerCoreHealthChecks.mockReset(); mocks.registerBundledHealthChecks.mockReset(); mocks.runDoctorHealthRepairs.mockReset(); + mocks.maybeRepairLegacyFlatAuthProfileStores.mockClear(); + mocks.maybeRepairLegacyFlatAuthProfileStores.mockResolvedValue(undefined); + mocks.maybeRepairCanonicalApiKeyFieldAlias.mockClear(); + mocks.maybeRepairCanonicalApiKeyFieldAlias.mockResolvedValue(undefined); + mocks.maybeRepairGatewayDaemon.mockClear(); + mocks.maybeRepairGatewayDaemon.mockResolvedValue(undefined); + mocks.maybeRepairLegacyOAuthProfileIds.mockClear(); + mocks.maybeRepairLegacyOAuthProfileIds.mockImplementation(async (cfg: unknown) => cfg); + mocks.maybeRepairLegacyOAuthSidecarProfiles.mockClear(); + mocks.maybeRepairLegacyOAuthSidecarProfiles.mockResolvedValue(undefined); + mocks.noteAuthProfileHealth.mockClear(); + mocks.noteAuthProfileHealth.mockResolvedValue(undefined); + mocks.noteLegacyCodexProviderOverride.mockClear(); + mocks.buildGatewayConnectionDetails.mockClear(); + mocks.buildGatewayConnectionDetails.mockReturnValue({ message: "gateway details" }); + mocks.resolveSecretInputRef.mockClear(); + mocks.resolveGatewayAuth.mockClear(); + mocks.resolveGatewayAuth.mockReturnValue({ mode: "token", token: undefined }); + mocks.resolveGatewayAuthToken.mockClear(); + mocks.resolveGatewayAuthToken.mockResolvedValue({ + source: "unavailable", + unresolvedRefReason: "exec provider failed", + }); + mocks.getSkippedExecRefStaticError.mockClear(); + mocks.getSkippedExecRefStaticError.mockReturnValue(undefined); + mocks.maybeRepairGatewayServiceConfig.mockClear(); + mocks.maybeRepairGatewayServiceConfig.mockResolvedValue(undefined); + mocks.maybeScanExtraGatewayServices.mockClear(); + mocks.maybeScanExtraGatewayServices.mockResolvedValue(undefined); + mocks.noteMacLaunchAgentOverrides.mockClear(); + mocks.noteMacLaunchctlGatewayEnvOverrides.mockClear(); + mocks.noteMacStaleOpenClawUpdateLaunchdJobs.mockClear(); + mocks.gatewaySecretInputPathCanWin.mockClear(); + mocks.gatewaySecretInputPathCanWin.mockReset(); + mocks.readGatewaySecretInputValue.mockClear(); + mocks.readGatewaySecretInputValue.mockReset(); + mocks.checkGatewayHealth.mockClear(); + mocks.checkGatewayHealth.mockResolvedValue({ + authenticated: true, + healthOk: true, + status: { ok: true }, + }); + mocks.probeGatewayMemoryStatus.mockClear(); + mocks.probeGatewayMemoryStatus.mockResolvedValue({ + checked: true, + ready: true, + skipped: false, + }); mocks.runDoctorHealthRepairs.mockResolvedValue({ config: {}, findings: [], @@ -173,12 +371,21 @@ describe("doctor health contributions", () => { }); mocks.listHealthChecks.mockReset(); mocks.listHealthChecks.mockReturnValue([ - { id: "core/doctor/shell-completion" }, - { id: "core/doctor/ui-protocol-freshness" }, - { id: "core/doctor/unrelated" }, + { id: "core/example/internal", kind: "core" }, + { id: "plugin/example/unrelated", kind: "plugin" }, ]); mocks.getHealthCheck.mockReset(); mocks.getHealthCheck.mockReturnValue(undefined); + mocks.registerHealthCheck.mockReset(); + mocks.noteChromeMcpBrowserReadiness.mockReset(); + mocks.noteChromeMcpBrowserReadiness.mockResolvedValue(undefined); + mocks.detectLegacyClawdBrowserProfileResidue.mockReset(); + mocks.detectLegacyClawdBrowserProfileResidue.mockReturnValue(null); + mocks.maybeArchiveLegacyClawdBrowserProfileResidue.mockReset(); + mocks.maybeArchiveLegacyClawdBrowserProfileResidue.mockResolvedValue({ + changes: [], + warnings: [], + }); mocks.resolveAgentWorkspaceDir.mockReset(); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); mocks.resolveDefaultAgentId.mockReset(); @@ -229,6 +436,7 @@ describe("doctor health contributions", () => { mocks.checkGatewayHealth.mockResolvedValue({ authenticated: false, healthOk: true, + status: { ok: true }, }); const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID); const ctx = { @@ -255,7 +463,12 @@ describe("doctor health contributions", () => { mocks.checkGatewayHealth.mockResolvedValue({ authenticated: false, healthOk: true, + status: { ok: true }, }); + mocks.gatewaySecretInputPathCanWin.mockImplementation( + ({ path }: { path: string }) => path === "gateway.auth.token", + ); + mocks.readGatewaySecretInputValue.mockReturnValue("exec-token"); const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID); const cfg = { gateway: { @@ -298,6 +511,10 @@ describe("doctor health contributions", () => { }); it("skips local gateway health probes for remote fallback exec SecretRefs", async () => { + mocks.gatewaySecretInputPathCanWin.mockImplementation( + ({ path }: { path: string }) => path === "gateway.remote.token", + ); + mocks.readGatewaySecretInputValue.mockReturnValue("exec-token"); const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID); const cfg = { gateway: { @@ -346,7 +563,7 @@ describe("doctor health contributions", () => { sourceConfigValid: true, prompter: buildDoctorPrompter(false), env: {}, - } as Parameters<(typeof contribution)["run"]>[0]; + } as unknown as Parameters<(typeof contribution)["run"]>[0]; await contribution.run(ctx); @@ -367,7 +584,7 @@ describe("doctor health contributions", () => { sourceConfigValid: true, prompter: buildDoctorPrompter(true), env: {}, - } as Parameters<(typeof contribution)["run"]>[0]; + } as unknown as Parameters<(typeof contribution)["run"]>[0]; await contribution.run(ctx); @@ -403,7 +620,7 @@ describe("doctor health contributions", () => { OPENCLAW_UPDATE_IN_PROGRESS: "1", OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", }, - } as Parameters<(typeof contribution)["run"]>[0]; + } as unknown as Parameters<(typeof contribution)["run"]>[0]; await contribution.run(ctx); @@ -633,7 +850,7 @@ describe("doctor health contributions", () => { const ctx = { cfg, options: {}, - } as Parameters<(typeof contribution)["run"]>[0]; + } as unknown as Parameters<(typeof contribution)["run"]>[0]; await contribution.run(ctx); @@ -649,6 +866,379 @@ describe("doctor health contributions", () => { ); }); + it("preserves allow-exec Gateway SecretRef resolution in auth health", async () => { + const contribution = requireDoctorContribution("doctor:gateway-auth"); + const ctx = { + cfg: { + gateway: { + mode: "local", + auth: { mode: "token", token: "exec-token" }, + }, + }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(false), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { allowExec: true, nonInteractive: true }, + env: { OPENCLAW_TEST_GATEWAY_TOKEN: "1" }, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.resolveGatewayAuthToken).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: ctx.cfg, + env: ctx.env, + unresolvedReasonStyle: "detailed", + envFallback: "never", + }), + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining( + "Gateway token SecretRef could not be resolved: exec provider failed", + ), + "Gateway auth", + ); + }); + + it("forwards allow-exec to Gateway service repair", async () => { + const contribution = requireDoctorContribution("doctor:gateway-services"); + const ctx = { + cfg: { gateway: { mode: "local" } }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { allowExec: true }, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.maybeRepairGatewayServiceConfig).toHaveBeenCalledWith( + ctx.cfg, + "local", + ctx.runtime, + ctx.prompter, + { allowExecSecretRefs: true }, + ); + }); + + it("skips Gateway health probes for exec SecretRefs unless allow-exec is set", async () => { + const contribution = requireDoctorContribution("doctor:gateway-health"); + mocks.gatewaySecretInputPathCanWin.mockImplementation( + ({ path }: { path: string }) => path === "gateway.auth.token", + ); + mocks.readGatewaySecretInputValue.mockReturnValue("exec-token"); + const ctx = { + cfg: { + gateway: { + mode: "local", + auth: { mode: "token", token: "exec-token" }, + }, + }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(false), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { nonInteractive: true }, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.checkGatewayHealth).not.toHaveBeenCalled(); + expect(ctx.gatewayHealthSkipped).toBe(true); + expect(ctx.gatewayMemoryProbe).toEqual({ checked: false, ready: false, skipped: true }); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Gateway health probes skipped"), + "Gateway", + ); + }); + + it("keeps canonical api_key alias repair wired through auth profile health", async () => { + const contribution = requireDoctorContribution("doctor:auth-profiles"); + const ctx = { + cfg: {}, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { nonInteractive: true }, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.maybeRepairLegacyFlatAuthProfileStores).toHaveBeenCalledWith({ + cfg: ctx.cfg, + prompter: ctx.prompter, + }); + expect(mocks.maybeRepairCanonicalApiKeyFieldAlias).toHaveBeenCalledWith({ + cfg: ctx.cfg, + prompter: ctx.prompter, + }); + }); + + it("forwards skipped Gateway health to daemon repair", async () => { + const contribution = requireDoctorContribution("doctor:gateway-daemon"); + const ctx = { + cfg: {}, + gatewayDetails: { message: "gateway details" }, + gatewayHealthSkipped: true, + healthOk: false, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { nonInteractive: true }, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.maybeRepairGatewayDaemon).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: ctx.cfg, + runtime: ctx.runtime, + prompter: ctx.prompter, + options: ctx.options, + gatewayDetailsMessage: "gateway details", + healthOk: false, + healthSkipped: true, + }), + ); + }); + + it("keeps implemented core health checks owned by ordered doctor contributions", async () => { + const coreIds = CORE_HEALTH_CHECKS.map((check) => check.id); + const contributionIds = resolveDoctorHealthContributions().flatMap( + (entry) => entry.healthCheckIds, + ); + const contributionChecks = await resolveDoctorContributionHealthChecks(); + + expect(new Set(contributionIds)).toEqual(new Set(coreIds)); + expect(contributionIds).toHaveLength(coreIds.length); + expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); + }); + + it("uses legacy run when a contribution also declares structured health", async () => { + const legacyRun = vi.fn(); + const healthChecks = { + description: "test legacy precedence", + detect: vi.fn(async () => []), + }; + const contribution = createDoctorHealthContribution({ + id: "doctor:test-legacy-wins", + label: "Test legacy wins", + healthChecks, + run: legacyRun, + }); + const ctx = { + cfg: {}, + cfgForPersistence: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + configPath: "/tmp/fake-openclaw.json", + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(legacyRun).toHaveBeenCalledWith(ctx); + expect(mocks.runDoctorHealthRepairs).not.toHaveBeenCalled(); + expect(contribution.healthCheckIds).toEqual(["core/doctor/test-legacy-wins"]); + expect(contribution.healthChecks).toMatchObject([ + { + id: "core/doctor/test-legacy-wins", + kind: "core", + source: "doctor", + }, + ]); + }); + + it("lets structured health own execution when legacy run is omitted", async () => { + const healthChecks = { + description: "test structured run", + detect: vi.fn(async () => []), + }; + mocks.runDoctorHealthRepairs.mockResolvedValue({ + config: { updated: true }, + findings: [], + remainingFindings: [], + changes: ["changed from structured health"], + warnings: ["structured warning"], + diffs: [], + effects: [], + checksRun: 1, + checksRepaired: 1, + checksValidated: 0, + }); + const contribution = createDoctorHealthContribution({ + id: "doctor:test-structured-run", + label: "Test structured run", + healthChecks, + }); + const ctx = { + cfg: {}, + cfgForPersistence: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + configPath: "/tmp/fake-openclaw.json", + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.runDoctorHealthRepairs).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/tmp/openclaw-workspace", + configPath: "/tmp/fake-openclaw.json", + }), + { + checks: contribution.healthChecks, + dryRun: false, + }, + ); + expect(ctx.cfg).toEqual({ updated: true }); + expect(ctx.cfgForPersistence).toEqual({}); + expect(ctx.runtime.error).toHaveBeenCalledWith("structured warning"); + expect(ctx.runtime.log).toHaveBeenCalledWith("changed from structured health"); + }); + + it("renders findings from structured health when legacy run is omitted", async () => { + const healthChecks = { + description: "test structured findings", + detect: vi.fn(async () => []), + }; + mocks.runDoctorHealthRepairs.mockResolvedValue({ + config: {}, + findings: [ + { + checkId: "core/doctor/test-structured-findings", + severity: "warning", + message: "structured finding needs attention", + path: "openclaw.json", + line: 12, + fixHint: "run openclaw doctor --fix", + }, + ], + remainingFindings: [], + changes: [], + warnings: [], + diffs: [], + effects: [], + checksRun: 1, + checksRepaired: 0, + checksValidated: 0, + }); + const contribution = createDoctorHealthContribution({ + id: "doctor:test-structured-findings", + label: "Test structured findings", + healthChecks, + }); + const ctx = { + cfg: {}, + cfgForPersistence: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(false), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + configPath: "/tmp/fake-openclaw.json", + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(ctx.runtime.log).toHaveBeenCalledWith( + "[warning] core/doctor/test-structured-findings openclaw.json:12 - structured finding needs attention", + ); + expect(ctx.runtime.log).toHaveBeenCalledWith(" fix: run openclaw doctor --fix"); + }); + + it("runs structured-only contributions in dry-run mode when doctor is not repairing", async () => { + const healthChecks = { + description: "test structured dry-run", + detect: vi.fn(async () => []), + }; + const contribution = createDoctorHealthContribution({ + id: "doctor:test-structured-dry-run", + label: "Test structured dry-run", + healthChecks, + }); + const ctx = { + cfg: {}, + cfgForPersistence: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(false), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + configPath: "/tmp/fake-openclaw.json", + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(mocks.runDoctorHealthRepairs).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/tmp/openclaw-workspace" }), + { + checks: contribution.healthChecks, + dryRun: true, + }, + ); + }); + + it("requires explicit health check ids for multi-check contributions", () => { + expect(() => + createDoctorHealthContribution({ + id: "doctor:test-multiple-checks", + label: "Test multiple checks", + healthChecks: [ + { + description: "first", + detect: vi.fn(async () => []), + }, + { + description: "second", + detect: vi.fn(async () => []), + }, + ], + }), + ).toThrow("must specify health check ids when it declares multiple healthChecks"); + }); + + it("repairs browser residue before browser readiness notes", async () => { + const calls: string[] = []; + mocks.runDoctorHealthRepairs.mockImplementation(async () => { + calls.push("repair"); + return { + config: {}, + findings: [], + remainingFindings: [], + changes: [], + warnings: [], + diffs: [], + effects: [], + checksRun: 1, + checksRepaired: 1, + checksValidated: 0, + }; + }); + mocks.noteChromeMcpBrowserReadiness.mockImplementation(async () => { + calls.push("note"); + }); + const contribution = requireDoctorContribution("doctor:browser"); + const ctx = { + cfg: {}, + cfgForPersistence: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + configPath: "/tmp/fake-openclaw.json", + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(calls).toEqual(["repair", "note"]); + }); + it("runs structured repairs before legacy skill repairs and config writes", () => { const ids = resolveDoctorHealthContributions().map((entry) => entry.id); @@ -661,7 +1251,7 @@ describe("doctor health contributions", () => { ); }); - it("keeps legacy positional repairs out of the broad structured repair pass", async () => { + it("keeps core-kind repairs out of the extension repair pass", async () => { const contribution = requireDoctorContribution("doctor:structured-health-repairs"); const ctx = { cfg: {}, @@ -673,15 +1263,63 @@ describe("doctor health contributions", () => { cfgForPersistence: {}, configPath: "/tmp/fake-openclaw.json", env: {}, - } as Parameters<(typeof contribution)["run"]>[0]; + } as unknown as Parameters<(typeof contribution)["run"]>[0]; await contribution.run(ctx); expect(mocks.runDoctorHealthRepairs).toHaveBeenCalledWith(expect.any(Object), { - checks: [{ id: "core/doctor/unrelated" }], + checks: [{ id: "plugin/example/unrelated", kind: "plugin" }], }); }); + it("rejects extension repairs that claim reserved core doctor ids", async () => { + mocks.listHealthChecks.mockReturnValue([ + { id: "plugin/example/unrelated", kind: "plugin" }, + { id: "core/doctor/shell-completion", kind: "plugin" }, + ]); + const contribution = requireDoctorContribution("doctor:structured-health-repairs"); + const ctx = { + cfg: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + cfgForPersistence: {}, + configPath: "/tmp/fake-openclaw.json", + env: {}, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await expect(contribution.run(ctx)).rejects.toThrow( + "health check already registered: core/doctor/shell-completion", + ); + expect(mocks.runDoctorHealthRepairs).not.toHaveBeenCalled(); + }); + + it("rejects registered core-kind repairs that claim reserved core doctor ids", async () => { + mocks.listHealthChecks.mockReturnValue([ + { id: "plugin/example/unrelated", kind: "plugin" }, + { id: "core/doctor/shell-completion", kind: "core" }, + ]); + const contribution = requireDoctorContribution("doctor:structured-health-repairs"); + const ctx = { + cfg: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + cfgForPersistence: {}, + configPath: "/tmp/fake-openclaw.json", + env: {}, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await expect(contribution.run(ctx)).rejects.toThrow( + "health check already registered: core/doctor/shell-completion", + ); + expect(mocks.runDoctorHealthRepairs).not.toHaveBeenCalled(); + }); + it("reports runtime tool schema blockers during normal doctor runs", async () => { const contribution = requireDoctorContribution("doctor:runtime-tool-schemas"); mocks.getHealthCheck.mockReturnValue({ @@ -710,7 +1348,7 @@ describe("doctor health contributions", () => { cfgForPersistence: {}, configPath: "/tmp/fake-openclaw.json", env: {}, - } as Parameters<(typeof contribution)["run"]>[0]; + } as unknown as Parameters<(typeof contribution)["run"]>[0]; await contribution.run(ctx); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 8558cc195d4b..253a2db25858 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -10,13 +10,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { buildGatewayConnectionDetails } from "../gateway/call.js"; import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-result.js"; import type { RuntimeEnv } from "../runtime.js"; -import type { HealthFinding } from "./health-checks.js"; +import { normalizeHealthCheck } from "./health-check-adapter.js"; +import type { HealthCheckInput, RunnableHealthCheck } from "./health-check-runner-types.js"; +import type { HealthCheck, HealthFinding } from "./health-checks.js"; import type { FlowContribution } from "./types.js"; -export { - doctorHealthConversionRules, - type DoctorHealthConversionKind, - type DoctorHealthConversionRule, -} from "./doctor-health-conversion-plan.js"; type DoctorFlowMode = "local" | "remote"; @@ -52,11 +49,24 @@ export type DoctorHealthFlowContext = { type DoctorHealthContribution = FlowContribution & { kind: "core"; surface: "health"; + // Structured checks listed here belong to this ordered doctor contribution; + // when legacy run() is absent they also own the doctor execution path. + healthChecks: readonly HealthCheckInput[]; healthCheckIds: readonly string[]; run: (ctx: DoctorHealthFlowContext) => Promise; }; -const PRE_HEALTH_POSITIONAL_HEALTH_CHECK_IDS = new Set(["core/doctor/ui-protocol-freshness"]); +type DoctorContributionHealthCheck = + | (Omit & { + readonly id?: string; + readonly kind?: "core"; + readonly source?: string; + }) + | (Omit & { + readonly id?: string; + readonly kind?: "core"; + readonly source?: string; + }); const loadAgentDefaultsModule = async () => await import("../agents/defaults.js"); const loadAgentScopeModule = async () => await import("../agents/agent-scope.js"); @@ -101,13 +111,22 @@ export function shouldSkipLegacyUpdateDoctorConfigWrite(params: { return true; } -function createDoctorHealthContribution(params: { +export function createDoctorHealthContribution(params: { id: string; label: string; healthCheckIds?: readonly string[]; + healthChecks?: DoctorContributionHealthCheck | readonly DoctorContributionHealthCheck[]; hint?: string; - run: (ctx: DoctorHealthFlowContext) => Promise; + run?: (ctx: DoctorHealthFlowContext) => Promise; }): DoctorHealthContribution { + const healthChecks = normalizeHealthChecks({ + contributionId: params.id, + healthChecks: params.healthChecks, + }); + const healthCheckIds = params.healthCheckIds ?? healthChecks.map((check) => check.id); + if (params.run === undefined && healthChecks.length === 0) { + throw new Error(`doctor contribution ${params.id} must define run or healthChecks`); + } return { id: params.id, kind: "core", @@ -118,22 +137,121 @@ function createDoctorHealthContribution(params: { ...(params.hint ? { hint: params.hint } : {}), }, source: "doctor", - healthCheckIds: params.healthCheckIds ?? [], - run: params.run, + healthChecks, + healthCheckIds, + run: + params.run ?? + ((ctx) => + runStructuredDoctorHealthContribution({ + contributionId: params.id, + ctx, + checks: healthChecks, + })), }; } -function resolvePositionalHealthCheckIds(): ReadonlySet { - const ids = new Set(PRE_HEALTH_POSITIONAL_HEALTH_CHECK_IDS); - for (const contribution of resolveDoctorHealthContributions()) { - if (contribution.id === "doctor:structured-health-repairs") { - continue; - } - for (const checkId of contribution.healthCheckIds) { - ids.add(checkId); +function normalizeHealthChecks(params: { + contributionId: string; + healthChecks?: DoctorContributionHealthCheck | readonly DoctorContributionHealthCheck[]; +}): readonly HealthCheckInput[] { + if (params.healthChecks === undefined) { + return []; + } + const checks = Array.isArray(params.healthChecks) ? params.healthChecks : [params.healthChecks]; + return checks.map((check) => + normalizeContributionHealthCheck({ + check, + contributionId: params.contributionId, + count: checks.length, + }), + ); +} + +function normalizeContributionHealthCheck(params: { + check: DoctorContributionHealthCheck; + contributionId: string; + count: number; +}): HealthCheckInput { + const id = + params.check.id ?? + (params.count === 1 ? deriveCoreHealthCheckId(params.contributionId) : undefined); + if (id === undefined) { + throw new Error( + `doctor contribution ${params.contributionId} must specify health check ids when it declares multiple healthChecks`, + ); + } + return { + ...params.check, + id, + kind: params.check.kind ?? "core", + source: params.check.source ?? "doctor", + }; +} + +function deriveCoreHealthCheckId(contributionId: string): string { + if (contributionId.startsWith("doctor:")) { + return `core/doctor/${contributionId.slice("doctor:".length)}`; + } + return `core/doctor/${contributionId}`; +} + +async function runStructuredDoctorHealthContribution(params: { + contributionId: string; + ctx: DoctorHealthFlowContext; + checks: readonly HealthCheckInput[]; +}): Promise { + if (params.checks.length === 0) { + throw new Error(`doctor contribution ${params.contributionId} has no structured health`); + } + const { runDoctorHealthRepairs } = await import("./doctor-repair-flow.js"); + const { resolveAgentWorkspaceDir, resolveDefaultAgentId } = + await import("../agents/agent-scope.js"); + const workspaceDir = resolveAgentWorkspaceDir( + params.ctx.cfg, + resolveDefaultAgentId(params.ctx.cfg), + ); + const result = await runDoctorHealthRepairs( + { + mode: "fix", + runtime: params.ctx.runtime, + cfg: params.ctx.cfg, + cwd: workspaceDir, + configPath: params.ctx.configPath, + dryRun: !params.ctx.prompter.shouldRepair, + allowExecSecretRefs: params.ctx.options.allowExec === true, + }, + { + checks: params.checks, + dryRun: !params.ctx.prompter.shouldRepair, + }, + ); + params.ctx.cfg = result.config; + renderStructuredHealthFindings(params.ctx, result.findings); + for (const warning of result.warnings) { + params.ctx.runtime.error(warning); + } + for (const change of result.changes) { + params.ctx.runtime.log(change); + } +} + +function renderStructuredHealthFindings( + ctx: DoctorHealthFlowContext, + findings: readonly HealthFinding[], +): void { + for (const finding of findings) { + const write = finding.severity === "error" ? ctx.runtime.error : ctx.runtime.log; + write(formatStructuredHealthFinding(finding)); + if (finding.fixHint !== undefined) { + ctx.runtime.log(` fix: ${finding.fixHint}`); } } - return ids; +} + +function formatStructuredHealthFinding(finding: HealthFinding): string { + const where = finding.path !== undefined ? ` ${finding.path}` : ""; + const line = finding.line !== undefined ? `:${finding.line}` : ""; + return `[${finding.severity}] ${finding.checkId}${where}${line} - ${finding.message}`; } async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise { @@ -321,18 +439,15 @@ async function runStructuredHealthRepairs(ctx: DoctorHealthFlowContext): Promise if (!ctx.prompter.shouldRepair) { return; } - const { registerCoreHealthChecks } = await loadDoctorCoreChecksModule(); const { registerBundledHealthChecks } = await import("./bundled-health-checks.js"); - const { listHealthChecks } = await loadHealthCheckRegistryModule(); + const { listExtensionHealthChecksForDoctor } = await loadHealthCheckRegistryModule(); const { runDoctorHealthRepairs } = await import("./doctor-repair-flow.js"); const { resolveAgentWorkspaceDir, resolveDefaultAgentId } = await loadAgentScopeModule(); const { note } = await loadNoteModule(); - registerCoreHealthChecks(); const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)); registerBundledHealthChecks({ cfg: ctx.cfg, cwd: workspaceDir }); - const positionalHealthCheckIds = resolvePositionalHealthCheckIds(); - const checks = listHealthChecks().filter((check) => !positionalHealthCheckIds.has(check.id)); + const checks = listExtensionHealthChecksForDoctor(await resolveDoctorContributionHealthChecks()); const result = await runDoctorHealthRepairs( { mode: "fix", @@ -357,6 +472,44 @@ async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise { noteClaudeCliHealth(ctx.cfg); } +async function runCoreContributionHealthRepair( + ctx: DoctorHealthFlowContext, + checkIds: readonly string[], +): Promise { + if (!ctx.prompter.shouldRepair || checkIds.length === 0) { + return; + } + const { CORE_HEALTH_CHECKS } = await import("./doctor-core-checks.js"); + const { runDoctorHealthRepairs } = await import("./doctor-repair-flow.js"); + const { resolveAgentWorkspaceDir, resolveDefaultAgentId } = + await import("../agents/agent-scope.js"); + const { note } = await import("../../packages/terminal-core/src/note.js"); + + const selectedIds = new Set(checkIds); + const checks = CORE_HEALTH_CHECKS.filter((check) => selectedIds.has(check.id)); + if (checks.length === 0) { + return; + } + const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)); + const result = await runDoctorHealthRepairs( + { + mode: "fix", + runtime: ctx.runtime, + cfg: ctx.cfg, + cwd: workspaceDir, + configPath: ctx.configPath, + }, + { checks }, + ); + ctx.cfg = result.config; + if (result.changes.length > 0) { + note(result.changes.join("\n"), "Doctor changes"); + } + if (result.warnings.length > 0) { + note(result.warnings.join("\n"), "Doctor warnings"); + } +} + async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise { const { detectLegacyStateMigrations, runLegacyStateMigrations } = await import("../commands/doctor-state-migrations.js"); @@ -568,13 +721,13 @@ async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise { async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise { const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js"); + await runCoreContributionHealthRepair(ctx, ["core/doctor/browser-clawd-profile-residue"]); await noteChromeMcpBrowserReadiness(ctx.cfg); } async function runOpenAIOAuthTlsHealth(ctx: DoctorHealthFlowContext): Promise { - const { noteOpenAIOAuthTlsPrerequisites } = await import( - "../plugins/provider-openai-chatgpt-oauth-tls.js" - ); + const { noteOpenAIOAuthTlsPrerequisites } = + await import("../plugins/provider-openai-chatgpt-oauth-tls.js"); await noteOpenAIOAuthTlsPrerequisites({ cfg: ctx.cfg, deep: ctx.options.deep === true, @@ -1111,6 +1264,12 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Plugin registry", run: runPluginRegistryHealth, }), + createDoctorHealthContribution({ + id: "doctor:ui-protocol-freshness", + label: "UI protocol freshness", + healthCheckIds: ["core/doctor/ui-protocol-freshness"], + run: async () => {}, + }), createDoctorHealthContribution({ id: "doctor:disk-space", label: "Disk space", @@ -1124,6 +1283,7 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:codex-session-routes", label: "Codex session routes", + healthCheckIds: ["core/doctor/codex-session-routes"], run: runCodexSessionRouteHealth, }), createDoctorHealthContribution({ @@ -1177,7 +1337,7 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:browser", label: "Browser", - healthCheckIds: ["core/doctor/browser"], + healthCheckIds: ["core/doctor/browser", "core/doctor/browser-clawd-profile-residue"], run: runBrowserHealth, }), createDoctorHealthContribution({ @@ -1288,6 +1448,28 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { ]; } +export async function resolveDoctorContributionHealthChecks(): Promise { + const { CORE_HEALTH_CHECKS } = await import("./doctor-core-checks.js"); + const checksById = new Map(CORE_HEALTH_CHECKS.map((check) => [check.id, check])); + const checks: HealthCheck[] = []; + for (const contribution of resolveDoctorHealthContributions()) { + if (contribution.healthChecks.length > 0) { + checks.push(...contribution.healthChecks.map(normalizeHealthCheck)); + continue; + } + for (const id of contribution.healthCheckIds) { + const check = checksById.get(id); + if (check === undefined) { + throw new Error( + `doctor contribution ${contribution.id} references unknown core health check ${id}`, + ); + } + checks.push(check); + } + } + return checks; +} + export async function runDoctorHealthContributions(ctx: DoctorHealthFlowContext): Promise { for (const contribution of resolveDoctorHealthContributions()) { await contribution.run(ctx); diff --git a/src/flows/doctor-health-conversion-plan.test.ts b/src/flows/doctor-health-conversion-plan.test.ts deleted file mode 100644 index c62b9ddee7c7..000000000000 --- a/src/flows/doctor-health-conversion-plan.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Doctor health conversion tests cover converting health checks to repair plans. -import { describe, expect, it } from "vitest"; -import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js"; -import { resolveDoctorHealthContributions } from "./doctor-health-contributions.js"; -import { doctorHealthConversionRules } from "./doctor-health-conversion-plan.js"; - -describe("doctor health conversion plan", () => { - it("classifies every current run contribution", () => { - const contributionIds = resolveDoctorHealthContributions().map( - (contribution) => contribution.id, - ); - const plannedIds = doctorHealthConversionRules.map((rule) => rule.contributionId); - - expect(plannedIds).toEqual(contributionIds); - }); - - it("keeps conversion targets explicit", () => { - for (const rule of doctorHealthConversionRules) { - expect(rule.target.length).toBeGreaterThan(0); - expect(rule.rule.trim()).not.toBe(""); - } - }); - - it("wires contributions only to registered core health check targets", () => { - const contributions = new Map( - resolveDoctorHealthContributions().map((contribution) => [contribution.id, contribution]), - ); - const registeredCoreIds = new Set(CORE_HEALTH_CHECKS.map((check) => check.id)); - - for (const rule of doctorHealthConversionRules) { - const contribution = contributions.get(rule.contributionId); - expect(contribution).toBeDefined(); - for (const id of contribution?.healthCheckIds ?? []) { - expect(rule.target).toContain(id); - expect(registeredCoreIds.has(id)).toBe(true); - } - } - }); -}); diff --git a/src/flows/doctor-health-conversion-plan.ts b/src/flows/doctor-health-conversion-plan.ts deleted file mode 100644 index f8981ca4297a..000000000000 --- a/src/flows/doctor-health-conversion-plan.ts +++ /dev/null @@ -1,291 +0,0 @@ -// Tracks migration of legacy doctor contributions into structured health checks. -export type DoctorHealthConversionKind = - | "already-detect" - | "detect-only" - | "repair-backed-detect" - | "split-detect-repair" - | "runtime-fact" - | "terminal-side-effect" - | "interactive-maintenance"; - -/** Describes one legacy doctor contribution and the structured health target replacing it. */ -export interface DoctorHealthConversionRule { - readonly contributionId: string; - readonly conversion: DoctorHealthConversionKind; - readonly target: readonly string[]; - readonly rule: string; -} - -/** Ordered conversion map used by tests and maintainers to keep doctor migration explicit. */ -export const doctorHealthConversionRules = [ - { - contributionId: "doctor:gateway-config", - conversion: "already-detect", - target: ["core/doctor/gateway-config"], - rule: "Keep as a pure config finding; doctor presentation should render the finding instead of calling note().", - }, - { - contributionId: "doctor:auth-profiles", - conversion: "split-detect-repair", - target: [ - "core/doctor/auth-profiles/flat-store", - "core/doctor/auth-profiles/oauth-sidecar", - "core/doctor/auth-profiles/oauth-ids", - "core/doctor/auth-profiles/keychain", - "core/doctor/auth-profiles/codex-provider", - ], - rule: "Split each legacy profile repair and keychain prompt into scoped findings; repairs update config only through repair().", - }, - { - contributionId: "doctor:claude-cli", - conversion: "detect-only", - target: ["core/doctor/claude-cli"], - rule: "Return CLI readiness findings with install/config hints; no config mutation.", - }, - { - contributionId: "doctor:gateway-auth", - conversion: "repair-backed-detect", - target: ["core/doctor/gateway-auth"], - rule: "Detect missing or externally unresolved Gateway auth; repair may generate token only when repair context explicitly allows it.", - }, - { - contributionId: "doctor:command-owner", - conversion: "already-detect", - target: ["core/doctor/command-owner"], - rule: "Keep as config-only owner finding.", - }, - { - contributionId: "doctor:structured-health-repairs", - conversion: "terminal-side-effect", - target: ["doctor-health-repair-runner", "core/doctor/ui-protocol-freshness"], - rule: "Delete this bridge after converted checks are registered directly; repair orchestration belongs outside the contribution list. UI freshness is registered for lint/dry-run effects while legacy doctor still owns real repair.", - }, - { - contributionId: "doctor:legacy-state", - conversion: "repair-backed-detect", - target: ["core/doctor/legacy-state"], - rule: "Detect migration preview as findings; repair runs selected migrations and reports changes/warnings.", - }, - { - contributionId: "doctor:legacy-plugin-manifests", - conversion: "repair-backed-detect", - target: ["core/doctor/legacy-plugin-manifests"], - rule: "Expose manifest contract drift as findings; repair delegates to manifest contract repair.", - }, - { - contributionId: "doctor:release-configured-plugin-installs", - conversion: "repair-backed-detect", - target: ["core/doctor/configured-plugin-installs"], - rule: "Detect configured plugins needing release repair; repair may touch meta.lastTouchedVersion and config entries.", - }, - { - contributionId: "doctor:plugin-registry", - conversion: "repair-backed-detect", - target: ["core/doctor/plugin-registry"], - rule: "Detect stale plugin registry state and let repair return the next config.", - }, - { - contributionId: "doctor:disk-space", - conversion: "terminal-side-effect", - target: ["doctor-run/disk-space"], - rule: "Currently emits low/critical free-space warnings via note(); convert to a path-scoped read-only finding (no repair) when the disk-space check gains a structured detector.", - }, - { - contributionId: "doctor:state-integrity", - conversion: "repair-backed-detect", - target: ["core/doctor/state-integrity"], - rule: "Convert orphan/legacy state notes to path-scoped findings; repair archives only selected findings.", - }, - { - contributionId: "doctor:codex-session-routes", - conversion: "repair-backed-detect", - target: ["core/doctor/codex-session-routes"], - rule: "Detect stale Codex route pins; repair updates affected session/config route records.", - }, - { - contributionId: "doctor:session-locks", - conversion: "repair-backed-detect", - target: ["core/doctor/session-locks"], - rule: "Detect stale session locks; repair removes only the locks represented by findings.", - }, - { - contributionId: "doctor:session-transcripts", - conversion: "repair-backed-detect", - target: ["core/doctor/session-transcripts"], - rule: "Detect transcript integrity issues; repair applies scoped transcript cleanup.", - }, - { - contributionId: "doctor:session-snapshots", - conversion: "repair-backed-detect", - target: ["doctor-run/session-snapshots"], - rule: "Keep this on the legacy doctor run path until the session snapshot scanner has a structured detector; do not register a clean core lint target before then.", - }, - { - contributionId: "doctor:config-audit-scrub", - conversion: "repair-backed-detect", - target: ["core/doctor/config-audit-scrub"], - rule: "Detect scrub-needed audit entries; repair rewrites only matching audit records.", - }, - { - contributionId: "doctor:legacy-cron", - conversion: "split-detect-repair", - target: ["core/doctor/legacy-cron-store", "core/doctor/legacy-whatsapp-crontab"], - rule: "Split crontab warning from cron store migration; repair only mutates cron store findings.", - }, - { - contributionId: "doctor:sandbox", - conversion: "split-detect-repair", - target: [ - "core/doctor/sandbox/registry-files", - "core/doctor/sandbox/images", - "core/doctor/sandbox-scope", - ], - rule: "Separate registry/image repairs from read-only sandbox scope warnings.", - }, - { - contributionId: "doctor:gateway-services", - conversion: "split-detect-repair", - target: [ - "core/doctor/gateway-services/extra", - "core/doctor/gateway-services/config", - "core/doctor/gateway-services/platform-notes", - ], - rule: "Model scans as findings; repair service config only when repair policy permits.", - }, - { - contributionId: "doctor:startup-channel-maintenance", - conversion: "repair-backed-detect", - target: ["core/doctor/startup-channel-maintenance"], - rule: "Detect startup channel maintenance work and run repair through the existing maintenance helper.", - }, - { - contributionId: "doctor:security", - conversion: "detect-only", - target: ["core/doctor/security"], - rule: "Return security posture warnings as findings with fix hints.", - }, - { - contributionId: "doctor:browser", - conversion: "detect-only", - target: ["core/doctor/browser"], - rule: "Return Chrome/MCP readiness findings without launching or repairing browser state.", - }, - { - contributionId: "doctor:oauth-tls", - conversion: "detect-only", - target: ["core/doctor/oauth-tls"], - rule: "Expose OAuth TLS prerequisites as findings; preserve deep-mode detail as finding metadata.", - }, - { - contributionId: "doctor:hooks-model", - conversion: "detect-only", - target: ["core/doctor/hooks-model"], - rule: "Detect allowlist/catalog issues for hooks.gmail.model as config findings.", - }, - { - contributionId: "doctor:tool-result-cap", - conversion: "detect-only", - target: ["core/doctor/tool-result-cap"], - rule: "Detect explicit live tool-result cap overrides that are stale or ineffective; preserve deep-mode effective cap output as finding metadata.", - }, - { - contributionId: "doctor:provider-catalog-projection", - conversion: "detect-only", - target: ["core/doctor/provider-catalog-projection"], - rule: "Validate provider catalog hooks against unified text catalog projection and report malformed plugin catalog rows during doctor.", - }, - { - contributionId: "doctor:runtime-tool-schemas", - conversion: "detect-only", - target: ["core/doctor/runtime-tool-schemas"], - rule: "Validate active agent tool schemas against the runtime tool projection path and report fatal schema blockers before a turn starts.", - }, - { - contributionId: "doctor:systemd-linger", - conversion: "interactive-maintenance", - target: ["core/doctor/systemd-linger"], - rule: "Detect missing linger as a Linux-only finding; interactive enablement remains a repair prompt.", - }, - { - contributionId: "doctor:workspace-status", - conversion: "already-detect", - target: ["core/doctor/workspace-status"], - rule: "Keep legacy workspace directory detection as a pure finding.", - }, - { - contributionId: "doctor:skills", - conversion: "already-detect", - target: ["core/doctor/skills-readiness"], - rule: "Keep unavailable skill detection/disable repair in the health registry.", - }, - { - contributionId: "doctor:bootstrap-size", - conversion: "detect-only", - target: ["core/doctor/bootstrap-size"], - rule: "Return oversized bootstrap files as path findings.", - }, - { - contributionId: "doctor:heartbeat-template-repair", - conversion: "repair-backed-detect", - target: ["core/doctor/heartbeat-template-repair"], - rule: "Detect legacy docs-wrapped heartbeat templates; repair only pure template wrappers and preserve user-authored heartbeat content.", - }, - { - contributionId: "doctor:shell-completion", - conversion: "interactive-maintenance", - target: ["core/doctor/shell-completion"], - rule: "Detect stale/missing completion setup; repair can delegate to completion installer when interactive.", - }, - { - contributionId: "doctor:gateway-health", - conversion: "runtime-fact", - target: ["doctor-runtime/gateway-status", "doctor-runtime/gateway-memory-probe"], - rule: "Prepare shared Gateway status/memory facts before checks; dependent checks must consume facts instead of probing again.", - }, - { - contributionId: "doctor:whatsapp-responsiveness", - conversion: "detect-only", - target: ["core/doctor/whatsapp-responsiveness"], - rule: "Detect WhatsApp degraded responsiveness from prepared Gateway status.", - }, - { - contributionId: "doctor:memory-search", - conversion: "split-detect-repair", - target: [ - "core/doctor/memory-search", - "core/doctor/memory-recall", - "core/doctor/memory-gateway-probe", - ], - rule: "Use prepared memory probe facts; keep recall repair separate from read-only search findings.", - }, - { - contributionId: "doctor:device-pairing", - conversion: "detect-only", - target: ["core/doctor/device-pairing"], - rule: "Report pairing readiness from prepared Gateway health facts.", - }, - { - contributionId: "doctor:gateway-daemon", - conversion: "repair-backed-detect", - target: ["core/doctor/gateway-daemon"], - rule: "Detect daemon drift from Gateway facts; repair delegates to daemon flow with scoped findings.", - }, - { - contributionId: "doctor:write-config", - conversion: "terminal-side-effect", - target: ["doctor-config-persistence"], - rule: "Keep config persistence as the final write step after repairs; it is not a health check.", - }, - { - contributionId: "doctor:workspace-suggestions", - conversion: "detect-only", - target: ["core/doctor/workspace-suggestions"], - rule: "Return workspace backup/memory-system suggestions as info findings when suggestions are enabled.", - }, - { - contributionId: "doctor:final-config-validation", - conversion: "already-detect", - target: ["core/doctor/final-config-validation"], - rule: "Keep final schema validation as a registered core check.", - }, -] as const satisfies readonly DoctorHealthConversionRule[]; diff --git a/src/flows/doctor-repair-flow.ts b/src/flows/doctor-repair-flow.ts index 676ac69805b6..3cbeb257ecec 100644 --- a/src/flows/doctor-repair-flow.ts +++ b/src/flows/doctor-repair-flow.ts @@ -4,9 +4,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { scrubDoctorErrorMessage } from "./doctor-error-message.js"; import { normalizeHealthCheck } from "./health-check-adapter.js"; import { listHealthChecks } from "./health-check-registry.js"; -import type { HealthCheckRunResult, RegisteredHealthCheck } from "./health-check-runner-types.js"; import type { - HealthCheck, + HealthCheckInput, + HealthCheckRunResult, + RegisteredHealthCheck, +} from "./health-check-runner-types.js"; +import type { HealthFinding, HealthRepairContext, HealthRepairDiff, @@ -16,7 +19,7 @@ import type { // Repair runner for structured doctor health checks; carries config between checks. export interface DoctorRepairRunOptions { - readonly checks?: readonly HealthCheck[]; + readonly checks?: readonly HealthCheckInput[]; readonly dryRun?: boolean; readonly diff?: boolean; } diff --git a/src/flows/health-check-registry.ts b/src/flows/health-check-registry.ts index 6bdcd87f5124..94185fee99ce 100644 --- a/src/flows/health-check-registry.ts +++ b/src/flows/health-check-registry.ts @@ -26,6 +26,20 @@ export function listHealthChecks(): readonly HealthCheck[] { return [...REGISTRY.values()]; } +/** Returns registered extension checks after rejecting any reserved core doctor id claims. */ +export function listExtensionHealthChecksForDoctor( + coreChecks: readonly HealthCheck[], +): readonly HealthCheck[] { + const coreIds = new Set(coreChecks.map((check) => check.id)); + const registeredChecks = listHealthChecks(); + for (const check of registeredChecks) { + if (check.id.startsWith("core/doctor/") || coreIds.has(check.id)) { + throw new HealthCheckRegistrationError(check.id); + } + } + return registeredChecks.filter((check) => check.kind !== "core"); +} + /** Looks up a registered health check by its stable id. */ export function getHealthCheck(id: string): HealthCheck | undefined { return REGISTRY.get(id);