Keep core doctor health in contribution order (#86627)

Merged via squash.

Prepared head SHA: e0955797c1
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
This commit is contained in:
Gio Della-Libera
2026-06-20 10:59:31 -07:00
committed by GitHub
parent b3968f69c9
commit e56fd1dc04
14 changed files with 1051 additions and 475 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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(
`${[
"<!-- Heartbeat template; comments-only content prevents scheduled heartbeat API calls. -->",
"",
"# 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",

View File

@@ -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",

View File

@@ -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<number> {
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 } : {}),
};

View File

@@ -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<string>(
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."),

View File

@@ -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;

View File

@@ -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<typeof import("../config/types.secrets.js")>();
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<typeof import("../gateway/credentials-secret-inputs.js")>();
return {
...actual,
gatewaySecretInputPathCanWin: (
...args: Parameters<typeof actual.gatewaySecretInputPathCanWin>
) =>
mocks.gatewaySecretInputPathCanWin.getMockImplementation()
? mocks.gatewaySecretInputPathCanWin(...args)
: actual.gatewaySecretInputPathCanWin(...args),
};
});
vi.mock("../gateway/secret-input-paths.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/secret-input-paths.js")>();
return {
...actual,
readGatewaySecretInputValue: (...args: Parameters<typeof actual.readGatewaySecretInputValue>) =>
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<typeof import("./health-check-registry.js")>();
return {
...actual,
listHealthChecks: mocks.listHealthChecks,
listExtensionHealthChecksForDoctor: (
coreChecks: Parameters<typeof actual.listExtensionHealthChecksForDoctor>[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<typeof import("../version.js")>("../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<typeof import("../utils.js")>()),
isRecord: mocks.isRecord,
shortenHomePath: mocks.shortenHomePath,
}));
vi.mock("../utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils.js")>();
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);

View File

@@ -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<void>;
};
const PRE_HEALTH_POSITIONAL_HEALTH_CHECK_IDS = new Set(["core/doctor/ui-protocol-freshness"]);
type DoctorContributionHealthCheck =
| (Omit<HealthCheck, "id" | "kind" | "source"> & {
readonly id?: string;
readonly kind?: "core";
readonly source?: string;
})
| (Omit<RunnableHealthCheck, "id" | "kind" | "source"> & {
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<void>;
run?: (ctx: DoctorHealthFlowContext) => Promise<void>;
}): 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<string> {
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<void> {
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<void> {
@@ -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<void> {
noteClaudeCliHealth(ctx.cfg);
}
async function runCoreContributionHealthRepair(
ctx: DoctorHealthFlowContext,
checkIds: readonly string[],
): Promise<void> {
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<void> {
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
await import("../commands/doctor-state-migrations.js");
@@ -568,13 +721,13 @@ async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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<void> {
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<readonly HealthCheck[]> {
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<void> {
for (const contribution of resolveDoctorHealthContributions()) {
await contribution.run(ctx);

View File

@@ -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);
}
}
});
});

View File

@@ -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[];

View File

@@ -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;
}

View File

@@ -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);