mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 19:59:35 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user