Doctor: add health-check contract and --lint validation (#80055)

* feat(doctor): add --lint mode + structured HealthFinding shape

Adds the core machinery for `openclaw doctor --lint` per the
doctor-lint-and-oc-rules upstream proposal. PR-1 of the proposal:
no new top-level verb, no public plugin SDK; everything internal.

Files:
- src/flows/checks.ts ? HealthFinding / HealthCheck / HealthCheckContext
   types. Findings carry severity per-finding; checks return
   readonly HealthFinding[]. Mode tag (doctor/lint/fix) lets a check
   distinguish the calling posture.
- src/flows/health-check-registry.ts ? module-level registry with
   duplicate-id rejection + test reset helper.
- src/flows/doctor-lint-flow.ts ? runner over registered checks.
   Catches throws into synthetic error findings (anchored at check id;
   message scrubbed of control chars, capped at 256 bytes). Sorts
   findings by severity desc, check id, path. Exports
   exitCodeFromFindings (1 if any warning/error, 0 otherwise).
- src/flows/doctor-core-checks.ts ? 4 modern HealthChecks rewriting
   logic from existing legacy run*Health functions:
     core/doctor/gateway-config            (warning)
     core/doctor/command-owner             (info)
     core/doctor/workspace-status          (info)
     core/doctor/final-config-validation   (error)
   Each was audited safe per the proposal's adapter constraints
   (no writes, no repair calls, no prompts, no probes incl. local-bind).
   Legacy run*Health contributions in doctor-health-contributions.ts
   are unchanged ? doctor mode (no --lint) still runs the existing 35.
- src/commands/doctor-lint.ts ? CLI dispatch for --lint. Reads config
   snapshot, builds HealthCheckContext (mode: "lint"), runs the registry,
   filters by --severity-min, emits human or JSON output, returns exit
   code from unfiltered set so --severity-min hides info findings
   without changing CI signal.
- src/cli/program/register.maintenance.ts ? adds --lint, --json,
   --severity-min, --skip, --only flags to existing doctor command.
   --lint branches to runDoctorLintCli; without --lint, doctor runs
   unchanged.

LoC: 382 src across 6 files. Tests + doc + oc-path-side rule packs
follow as separate commits on this branch.

* fix: avoid string spread in doctor errors

* chore: refresh plugin SDK API baseline

* docs: clarify doctor lint usage

* feat(doctor): prepare repairs for dry-run reporting
This commit is contained in:
Gio Della-Libera
2026-05-17 12:29:57 -07:00
committed by GitHub
parent 0dc04fb926
commit 9a5f2f61e7
33 changed files with 1771 additions and 35 deletions

View File

@@ -1,2 +1,2 @@
d979b8c2721eeb83380a38853309e9ba0f2c28e040a9ad2ee1e7b2ab10c547db plugin-sdk-api-baseline.json
4815f711fe2481483159137cfb97ce3d1c173e0b50a364a2353f49888f4d53df plugin-sdk-api-baseline.jsonl
048d8ff5e4455d16f75f6762a916f67c982e1211fb7085456647234255567466 plugin-sdk-api-baseline.json
2d46a9660c9143f823a47df3c7ecfd315a4999e96af5eddb4ba4e71d9bb377a6 plugin-sdk-api-baseline.jsonl

View File

@@ -15,13 +15,34 @@ Related:
- Troubleshooting: [Troubleshooting](/gateway/troubleshooting)
- Security audit: [Security](/gateway/security)
## Why Use It
`openclaw doctor` is the OpenClaw health surface. Use it when the gateway,
channels, plugins, skills, model routing, local state, or config migrations are
not behaving as expected and you want one command that can explain what is
wrong.
Doctor has three postures:
| Posture | Command | Behavior |
| ------- | ------------------------ | ------------------------------------------------------------------------------- |
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a
human operator intentionally wants doctor to edit config or state.
## Examples
```bash
openclaw doctor
openclaw doctor --repair
openclaw doctor --lint
openclaw doctor --lint --json
openclaw doctor --lint --severity-min warning
openclaw doctor --deep
openclaw doctor --repair --non-interactive
openclaw doctor --fix
openclaw doctor --fix --non-interactive
openclaw doctor --generate-gateway-token
```
@@ -44,13 +65,134 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
- `--generate-gateway-token`: generate and configure a gateway token
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
- `--json`: with `--lint`, emit JSON findings instead of human output
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
## Lint mode
`openclaw doctor --lint` is the read-only automation posture for doctor checks.
It uses the structured health-check path, does not prompt, and does not repair
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
when you want machine-readable findings instead of guided repair prompts.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
are only accepted with `--lint`.
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --only core/doctor/gateway-config --json
```
Human output is compact:
```text
doctor --lint: ran 5 check(s), 1 finding(s)
[warning] core/doctor/gateway-config gateway.mode - gateway.mode is unset; gateway start will be blocked.
fix: Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`.
```
JSON output is the scripting surface for lint runs:
```json
{
"ok": false,
"checksRun": 5,
"checksSkipped": 0,
"findings": [
{
"checkId": "core/doctor/gateway-config",
"severity": "warning",
"message": "gateway.mode is unset; gateway start will be blocked.",
"path": "gateway.mode",
"fixHint": "Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`."
}
]
}
```
Exit behavior:
- `0`: no findings at or above the selected severity threshold
- `1`: at least one finding meets the selected threshold
- `2`: command/runtime failure before lint findings can be produced
`--severity-min` controls both visible findings and the exit threshold. For
example, `openclaw doctor --lint --severity-min error` can print no findings and
exit `0` even when lower-severity `info` or `warning` findings exist.
## Structured Health Checks
Modern doctor checks use a small structured contract:
```ts
detect(ctx, scope?) -> HealthFinding[]
repair?(ctx, findings) -> HealthRepairResult
```
`detect()` powers `doctor --lint`. `repair()` is optional and is only considered
by `doctor --fix` / `doctor --repair`. Checks that have not migrated to this
shape continue to use the legacy doctor contribution flow.
The split is intentional: `detect()` owns diagnosis, while `repair()` owns
reporting what it changed or would change. Repair contexts can carry
`dryRun`/`diff` requests, and repair results can return structured `diffs` for
config/file edits plus `effects` for service, process, package, state, or other
side effects. That lets converted checks grow toward `doctor --fix --dry-run`
and diff reporting without moving mutation planning into `detect()`.
`repair()` reports whether it attempted the requested repair with `status:
"repaired" | "skipped" | "failed"`. Omitted status means `repaired`, so simple
repair checks only need to return changes. When repair returns `skipped` or
`failed`, doctor reports the reason and does not run validation for that check.
After a successful structured repair, doctor re-runs `detect()` with the
repaired findings as scope. Checks can use selected findings, paths, or `ocPath`
values for focused validation. If the finding is still present, doctor reports a
repair warning instead of treating the change as silently complete.
A finding includes:
| Field | Purpose |
| ----------------- | ------------------------------------------------------ |
| `checkId` | Stable id for skip/only filters and CI allowlists. |
| `severity` | `info`, `warning`, or `error`. |
| `message` | Human-readable problem statement. |
| `path` | Config, file, or logical path when available. |
| `line` / `column` | Source location when available. |
| `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.
## Check Selection
Use `--only` and `--skip` when a workflow wants a focused gate:
```bash
openclaw doctor --lint --only core/doctor/gateway-config --json
openclaw doctor --lint --skip core/doctor/skills-readiness
```
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`
id is not registered, no check runs for that id; use the command's `checksRun`
and `checksSkipped` fields to verify a focused gate is selecting the checks you
expect.
Notes:
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution.
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive doctor sessions still load the plugin surfaces needed by the legacy health and repair flow.
- `--lint` is stricter than `--non-interactive`: it is always read-only, never prompts, and never applies safe migrations. Run `doctor --fix` or `doctor --repair` when you want doctor to make changes.
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.

View File

@@ -26,17 +26,28 @@ openclaw doctor
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
</Tab>
<Tab title="--repair">
<Tab title="--fix">
```bash
openclaw doctor --repair
openclaw doctor --fix
```
Apply recommended repairs without prompting (repairs + restarts where safe).
</Tab>
<Tab title="--repair --force">
<Tab title="--lint">
```bash
openclaw doctor --repair --force
openclaw doctor --lint
openclaw doctor --lint --json
```
Run structured health checks for CI or preflight automation. This mode is
read-only: it does not prompt, repair, migrate config, restart services, or
touch state.
</Tab>
<Tab title="--fix --force">
```bash
openclaw doctor --fix --force
```
Apply aggressive repairs too (overwrites custom supervisor configs).
@@ -66,6 +77,57 @@ If you want to review changes before writing, open the config file first:
cat ~/.openclaw/openclaw.json
```
## Read-only lint mode
`openclaw doctor --lint` is the automation-friendly sibling of
`openclaw doctor --fix`. Both use doctor health checks, but their posture is
different:
| Mode | Prompts | Writes config/state | Output | Use it for |
| ------------------------ | --------- | ----------------------- | ---------------------- | ------------------------------- |
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
Modernized health checks may provide an optional `repair()` implementation.
`doctor --fix` applies those repairs when they exist and continues to use the
existing doctor repair flow for checks that have not migrated yet.
The structured repair contract also separates repair reporting from detection:
`detect()` reports current findings, while `repair()` can report changes,
config/file diffs, and non-file side effects. That keeps the migration path open
for future `doctor --fix --dry-run` and diff output without making lint checks
plan mutations.
Examples:
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --only core/doctor/gateway-config --json
```
JSON output includes:
- `ok`: whether any visible finding met the selected severity threshold
- `checksRun`: number of health checks executed
- `checksSkipped`: checks skipped by `--only` or `--skip`
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
Exit codes:
- `0`: no findings at or above the selected threshold
- `1`: one or more findings met the selected threshold
- `2`: command/runtime failure before lint findings could be emitted
Use `--severity-min info|warning|error` to control both what is printed and what
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
lint run active.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
must be paired with `--lint`; regular doctor and repair runs reject them.
## What it does (summary)
<AccordionGroup>
@@ -471,8 +533,8 @@ That stages grounded durable candidates into the short-term dreaming store while
- `openclaw doctor` prompts before rewriting supervisor config.
- `openclaw doctor --yes` accepts the default repair prompts.
- `openclaw doctor --repair` applies recommended fixes without prompts.
- `openclaw doctor --repair --force` overwrites custom supervisor configs.
- `openclaw doctor --fix` applies recommended fixes without prompts (`--repair` is an alias).
- `openclaw doctor --fix --force` overwrites custom supervisor configs.
- `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle.
- On Linux, doctor does not rewrite command/entrypoint metadata while the matching systemd gateway unit is active. It also ignores inactive non-legacy extra gateway-like units during the duplicate-service scan so companion service files do not create cleanup noise.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.

View File

@@ -197,8 +197,7 @@ function subagentFanoutTaskForProvider(
worker: "alpha" | "beta",
) {
const marker = worker === "alpha" ? "ALPHA-OK" : "BETA-OK";
const scope =
providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
const scope = providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
return `Fanout worker ${worker}: inspect ${scope} and finish with exactly ${marker}.`;
}

View File

@@ -132,6 +132,10 @@
"types": "./dist/plugin-sdk/runtime.d.ts",
"default": "./dist/plugin-sdk/runtime.js"
},
"./plugin-sdk/health": {
"types": "./dist/plugin-sdk/health.d.ts",
"default": "./dist/plugin-sdk/health.js"
},
"./plugin-sdk/runtime-doctor": {
"types": "./dist/plugin-sdk/runtime-doctor.d.ts",
"default": "./dist/plugin-sdk/runtime-doctor.js"

View File

@@ -1,6 +1,6 @@
import { performance } from "node:perf_hooks";
import { accessSync, constants } from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import {
detectChangedLanesForPaths,
listChangedPathsFromGit,
@@ -66,12 +66,9 @@ function executableExistsOnPath(command, env = process.env) {
export function shouldSkipAppLintForMissingSwiftlint(options = {}) {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const swiftlintAvailable =
options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
const swiftlintAvailable = options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
return (
isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN) &&
platform !== "darwin" &&
!swiftlintAvailable
isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN) && platform !== "darwin" && !swiftlintAvailable
);
}

View File

@@ -17,6 +17,9 @@ export const pluginSdkDocMetadata = {
core: {
category: "core",
},
health: {
category: "core",
},
"approval-runtime": {
category: "runtime",
},

View File

@@ -8,6 +8,7 @@
"self-hosted-provider-setup",
"routing",
"runtime",
"health",
"runtime-doctor",
"runtime-env",
"runtime-logger",

View File

@@ -269,7 +269,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{ commandPath: ["dashboard"], policy: { networkProxy: "bypass" } },
{ commandPath: ["daemon"], policy: { networkProxy: "bypass" } },
{ commandPath: ["devices"], policy: { networkProxy: "bypass" } },
{ commandPath: ["doctor"], policy: { bypassConfigGuard: true } },
{
commandPath: ["doctor"],
policy: {
bypassConfigGuard: true,
loadPlugins: "never",
},
},
{ commandPath: ["exec-policy"], policy: { networkProxy: "bypass" } },
{ commandPath: ["hooks"], policy: { networkProxy: "bypass" } },
{ commandPath: ["logs"], policy: { networkProxy: "bypass" } },

View File

@@ -198,6 +198,10 @@ describe("command-path-policy", () => {
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["doctor"], {
bypassConfigGuard: true,
loadPlugins: "never",
});
expectResolvedPolicy(["config", "validate"], {
bypassConfigGuard: true,
loadPlugins: "never",

View File

@@ -134,7 +134,7 @@ describe("registerPreActionHooks", () => {
.command("create")
.option("--json")
.action(() => {});
program.command("doctor").action(() => {});
program.command("doctor").option("--lint").action(() => {});
program.command("completion").action(() => {});
program.command("secrets").action(() => {});
program
@@ -334,6 +334,16 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("skips the config guard and plugin loading for doctor lint", async () => {
await runPreAction({
parseArgv: ["doctor"],
processArgv: ["node", "openclaw", "doctor", "--lint"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("only allows invalid config for explicit official recovery reinstall requests", async () => {
await runPreAction({
parseArgv: ["plugins", "install", "@openclaw/discord"],
@@ -529,6 +539,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for config validate when root option values are present", async () => {
@@ -538,6 +549,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for config schema", async () => {
@@ -547,6 +559,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for backup create", async () => {
@@ -556,6 +569,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
@@ -595,7 +609,7 @@ describe("registerPreActionHooks", () => {
preAction?: Array<(thisCommand: Command, actionCommand: Command) => Promise<void> | void>;
};
}
)._lifeCycleHooks?.preAction;
)["_lifeCycleHooks"]?.preAction;
preActionHook = hooks?.[0] ?? null;
});
});

View File

@@ -133,6 +133,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
commandPath,
startupPolicy,
allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath),
skipConfigGuard: shouldBypassConfigGuardForCommandPath(commandPath),
});
});
}

View File

@@ -12,9 +12,17 @@ const mocks = vi.hoisted(() => ({
error: vi.fn(),
exit: vi.fn(),
},
runDoctorLintCli: vi.fn(),
}));
const { doctorCommand, dashboardCommand, resetCommand, uninstallCommand, runtime } = mocks;
const {
doctorCommand,
dashboardCommand,
resetCommand,
uninstallCommand,
runtime,
runDoctorLintCli,
} = mocks;
vi.mock("../../commands/doctor.js", () => ({
doctorCommand: mocks.doctorCommand,
@@ -32,6 +40,10 @@ vi.mock("../../commands/uninstall.js", () => ({
uninstallCommand: mocks.uninstallCommand,
}));
vi.mock("../../commands/doctor-lint.js", () => ({
runDoctorLintCli: mocks.runDoctorLintCli,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
@@ -89,6 +101,51 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(options.repair).toBe(true);
});
it("runs doctor lint mode without invoking repair doctor", async () => {
runDoctorLintCli.mockResolvedValue(1);
await runMaintenanceCli([
"doctor",
"--lint",
"--json",
"--severity-min",
"error",
"--skip",
"a",
"--only",
"b",
]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, {
json: true,
severityMin: "error",
skipIds: ["a"],
onlyIds: ["b"],
});
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("exits with code 2 when doctor lint mode fails before findings are emitted", async () => {
runDoctorLintCli.mockRejectedValue(new Error("lint failed"));
await runMaintenanceCli(["doctor", "--lint"]);
expect(runtime.error).toHaveBeenCalledWith("Error: lint failed");
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("rejects lint-only selectors outside lint mode", async () => {
await runMaintenanceCli(["doctor", "--only", "core/example"]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
);
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("passes noOpen to dashboard command", async () => {
dashboardCommand.mockResolvedValue(undefined);

View File

@@ -25,7 +25,52 @@ export function registerMaintenanceCommands(program: Command) {
.option("--non-interactive", "Run without prompts (safe migrations only)", false)
.option("--generate-gateway-token", "Generate and configure a gateway token", false)
.option("--deep", "Scan system services for extra gateway installs", false)
.option("--lint", "Run read-only health checks and report findings", false)
.option("--json", "With --lint: emit JSON findings instead of human output", false)
.option(
"--severity-min <level>",
"With --lint: drop findings below this severity (info|warning|error)",
)
.option(
"--skip <id>",
"With --lint: skip a specific check id (repeatable)",
(v: string, prev: string[]) => [...prev, v],
[],
)
.option(
"--only <id>",
"With --lint: run only the specified check id (repeatable)",
(v: string, prev: string[]) => [...prev, v],
[],
)
.action(async (opts) => {
if (opts.lint === true) {
await runCommandWithRuntime(
defaultRuntime,
async () => {
const { runDoctorLintCli } = await import("../../commands/doctor-lint.js");
const exitCode = await runDoctorLintCli(defaultRuntime, {
json: Boolean(opts.json),
severityMin: typeof opts.severityMin === "string" ? opts.severityMin : undefined,
skipIds: Array.isArray(opts.skip) ? opts.skip : [],
onlyIds: Array.isArray(opts.only) ? opts.only : [],
});
defaultRuntime.exit(exitCode);
},
(err) => {
defaultRuntime.error(String(err));
defaultRuntime.exit(2);
},
);
return;
}
if (hasLintOnlyDoctorOptions(opts)) {
defaultRuntime.error(
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
);
defaultRuntime.exit(2);
return;
}
await runCommandWithRuntime(defaultRuntime, async () => {
await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions,
@@ -113,3 +158,17 @@ export function registerMaintenanceCommands(program: Command) {
});
});
}
function hasLintOnlyDoctorOptions(opts: {
readonly json?: boolean;
readonly severityMin?: unknown;
readonly skip?: unknown;
readonly only?: unknown;
}): boolean {
return (
opts.json === true ||
typeof opts.severityMin === "string" ||
(Array.isArray(opts.skip) && opts.skip.length > 0) ||
(Array.isArray(opts.only) && opts.only.length > 0)
);
}

View File

@@ -2757,8 +2757,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
postCoreConfigSnapshot,
preUpdateSourceConfig,
);
const parentPluginInstallRecords =
await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath);
const parentPluginInstallRecords = await readPostCorePluginInstallRecordsFile(
postCoreInstallRecordsPath,
);
// The updated doctor may have repaired plugin installs before this fresh process resumed.
const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const pluginInstallRecords =

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetCoreHealthChecksForTest } from "../flows/doctor-core-checks.js";
import { clearHealthChecksForTest } from "../flows/health-check-registry.js";
import { runDoctorLintCli } from "./doctor-lint.js";
const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
describe("runDoctorLintCli", () => {
beforeEach(() => {
vi.clearAllMocks();
clearHealthChecksForTest();
resetCoreHealthChecksForTest();
});
it("bases exit code on the selected severity threshold", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {},
path: "/tmp/openclaw.json",
});
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
const exitCode = await runDoctorLintCli(runtime, {
json: true,
severityMin: "error",
});
expect(exitCode).toBe(0);
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false });
expect(String(stdout.mock.calls.at(-1)?.[0])).toContain('"findings":[]');
} finally {
stdout.mockRestore();
}
});
it("reports the visible finding count in human output", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {},
path: "/tmp/openclaw.json",
});
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const originalIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
const exitCode = await runDoctorLintCli(runtime, {
severityMin: "error",
});
expect(exitCode).toBe(0);
expect(String(stdout.mock.calls[0]?.[0])).toBe(
"doctor --lint: ran 5 check(s), 0 finding(s)\n",
);
expect(String(stdout.mock.calls[1]?.[0])).toBe(" no findings\n");
} finally {
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsTTY });
stdout.mockRestore();
}
});
it("emits structured JSON for invalid config snapshots", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
path: "/tmp/openclaw.json",
issues: [{ path: "gateway.mode", message: "Required" }],
});
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
const exitCode = await runDoctorLintCli(runtime, { json: true });
expect(exitCode).toBe(1);
const payload = JSON.parse(String(stdout.mock.calls.at(-1)?.[0]));
expect(payload).toMatchObject({
ok: false,
checksRun: 1,
findings: [
{
checkId: "core/doctor/final-config-validation",
severity: "error",
message: "Required",
path: "gateway.mode",
},
],
});
expect(runtime.error).not.toHaveBeenCalled();
} finally {
stdout.mockRestore();
}
});
it("rejects invalid severity thresholds", async () => {
await expect(runDoctorLintCli(runtime, { severityMin: "warnng" })).rejects.toThrow(
"Invalid --severity-min value",
);
});
});

138
src/commands/doctor-lint.ts Normal file
View File

@@ -0,0 +1,138 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { readConfigFileSnapshot } from "../config/config.js";
import {
configValidationIssuesToHealthFindings,
registerCoreHealthChecks,
} from "../flows/doctor-core-checks.js";
import {
exitCodeFromFindings,
runDoctorLintChecks,
type DoctorLintRunOptions,
} from "../flows/doctor-lint-flow.js";
import {
healthFindingMeetsSeverity,
parseHealthFindingSeverity,
type HealthCheckContext,
type HealthFinding,
} from "../flows/health-checks.js";
import type { RuntimeEnv } from "../runtime.js";
export interface DoctorLintCliOptions {
readonly json?: boolean;
readonly severityMin?: string;
readonly skipIds?: readonly string[];
readonly onlyIds?: readonly string[];
}
function detectMode(opts: DoctorLintCliOptions): "human" | "json" {
if (opts.json === true) {
return "json";
}
return process.stdout.isTTY ? "human" : "json";
}
export async function runDoctorLintCli(
runtime: RuntimeEnv,
opts: DoctorLintCliOptions,
): Promise<number> {
registerCoreHealthChecks();
const sevMin =
opts.severityMin === undefined ? "info" : parseHealthFindingSeverity(opts.severityMin);
if (sevMin === null) {
throw new Error("Invalid --severity-min value. Expected one of: info, warning, error.");
}
const snapshot = await readConfigFileSnapshot({ observe: false });
if (snapshot.exists && !snapshot.valid) {
const findings = configValidationIssuesToHealthFindings(snapshot.issues);
const visible = findings.filter((finding) => healthFindingMeetsSeverity(finding, sevMin));
if (detectMode(opts) === "json") {
writeJsonResult({
ok: false,
checksRun: 1,
checksSkipped: 0,
findings: visible,
});
} else {
runtime.error("doctor --lint: config file exists but does not parse cleanly.");
for (const issue of snapshot.issues) {
const path = issue.path || "<root>";
runtime.error(`- ${path}: ${issue.message}`);
}
}
return exitCodeFromFindings(findings, sevMin);
}
const ctx: HealthCheckContext = {
mode: "lint",
runtime,
cfg: snapshot.config,
cwd: resolveAgentWorkspaceDir(snapshot.config, resolveDefaultAgentId(snapshot.config)),
...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}),
};
const runOpts: DoctorLintRunOptions = {
...(opts.skipIds && opts.skipIds.length > 0 ? { skipIds: opts.skipIds } : {}),
...(opts.onlyIds && opts.onlyIds.length > 0 ? { onlyIds: opts.onlyIds } : {}),
};
const result = await runDoctorLintChecks(ctx, runOpts);
const visible = result.findings.filter((finding) => healthFindingMeetsSeverity(finding, sevMin));
const mode = detectMode(opts);
if (mode === "json") {
writeJsonResult({
ok: exitCodeFromFindings(result.findings, sevMin) === 0,
checksRun: result.checksRun,
checksSkipped: result.checksSkipped,
findings: visible,
});
} else {
process.stdout.write(
`doctor --lint: ran ${result.checksRun} check(s), ${visible.length} finding(s)\n`,
);
if (visible.length === 0) {
process.stdout.write(" no findings\n");
} else {
for (const f of visible) {
const where = f.path !== undefined ? ` ${f.path}` : "";
const line = f.line !== undefined ? `:${f.line}` : "";
process.stdout.write(` [${f.severity}] ${f.checkId}${where}${line} - ${f.message}\n`);
if (f.fixHint !== undefined) {
process.stdout.write(` fix: ${f.fixHint}\n`);
}
}
}
}
return exitCodeFromFindings(result.findings, sevMin);
}
function writeJsonResult(result: {
ok: boolean;
checksRun: number;
checksSkipped: number;
findings: readonly HealthFinding[];
}): void {
process.stdout.write(
JSON.stringify({
ok: result.ok,
checksRun: result.checksRun,
checksSkipped: result.checksSkipped,
findings: result.findings.map(toJsonFinding),
}) + "\n",
);
}
function toJsonFinding(f: HealthFinding): Record<string, unknown> {
return {
checkId: f.checkId,
severity: f.severity,
message: f.message,
...(f.source !== undefined ? { source: f.source } : {}),
...(f.path !== undefined ? { path: f.path } : {}),
...(f.line !== undefined ? { line: f.line } : {}),
...(f.column !== undefined ? { column: f.column } : {}),
...(f.ocPath !== undefined ? { ocPath: f.ocPath } : {}),
...(f.fixHint !== undefined ? { fixHint: f.fixHint } : {}),
};
}

View File

@@ -40,6 +40,7 @@ export type {
RuntimeConfigSnapshotMetadata,
} from "./runtime-snapshot.js";
export type {
ConfigSnapshotReadOptions,
ConfigWriteNotification,
ConfigWriteResult,
ReadConfigFileSnapshotWithPluginMetadataResult,

View File

@@ -8,6 +8,23 @@ import {
import { withTempHome, writeOpenClawConfig } from "./test-helpers.js";
describe("readBestEffortConfig", () => {
it("can read snapshots without updating config observation state", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
gateway: { mode: "local" },
});
await readConfigFileSnapshot({ observe: false });
const healthPath = `${home}/.openclaw/logs/config-health.json`;
await expect(fs.stat(healthPath)).rejects.toMatchObject({ code: "ENOENT" });
await readConfigFileSnapshot();
await expect(fs.stat(healthPath)).resolves.toMatchObject({ isFile: expect.any(Function) });
});
});
it("does not restore suspicious direct edits from .bak during ordinary reads", async () => {
await withTempHome(async (home) => {
const configPath = await writeOpenClawConfig(home, {

View File

@@ -887,6 +887,14 @@ export type ConfigIoDeps = {
logger?: Pick<typeof console, "error" | "warn">;
measure?: ConfigSnapshotReadMeasure;
suppressFutureVersionWarning?: boolean;
observe?: boolean;
};
export type ConfigSnapshotReadOptions = {
measure?: ConfigSnapshotReadMeasure;
observe?: boolean;
skipPluginValidation?: boolean;
preservedLegacyRootKeys?: readonly string[];
};
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
@@ -946,6 +954,7 @@ function normalizeDeps(overrides: ConfigIoDeps = {}): Required<ConfigIoDeps> {
logger: overrides.logger ?? console,
measure: overrides.measure ?? (async (_name, run) => await run()),
suppressFutureVersionWarning: overrides.suppressFutureVersionWarning ?? false,
observe: overrides.observe ?? true,
};
}
@@ -1218,7 +1227,9 @@ async function finalizeReadConfigSnapshotInternalResult(
deps: Required<ConfigIoDeps>,
result: ReadConfigFileSnapshotInternalResult,
): Promise<ReadConfigFileSnapshotInternalResult> {
await observeConfigSnapshot(deps, result.snapshot);
if (deps.observe) {
await observeConfigSnapshot(deps, result.snapshot);
}
return result;
}
@@ -2382,15 +2393,14 @@ export async function readSourceConfigBestEffort(): Promise<OpenClawConfig> {
return await createConfigIO().readSourceConfigBestEffort();
}
export async function readConfigFileSnapshot(options?: {
measure?: ConfigSnapshotReadMeasure;
skipPluginValidation?: boolean;
preservedLegacyRootKeys?: readonly string[];
}): Promise<ConfigFileSnapshot> {
export async function readConfigFileSnapshot(
options: ConfigSnapshotReadOptions = {},
): Promise<ConfigFileSnapshot> {
return await createConfigIO({
...(options?.measure ? { measure: options.measure } : {}),
...(options?.skipPluginValidation ? { pluginValidation: "skip" } : {}),
...(options?.preservedLegacyRootKeys
...(options.measure ? { measure: options.measure } : {}),
...(options.observe === false ? { observe: false } : {}),
...(options.skipPluginValidation ? { pluginValidation: "skip" } : {}),
...(options.preservedLegacyRootKeys
? { preservedLegacyRootKeys: options.preservedLegacyRootKeys }
: {}),
}).readConfigFileSnapshot();

View File

@@ -0,0 +1,150 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
CORE_HEALTH_CHECKS,
registerCoreHealthChecks,
resetCoreHealthChecksForTest,
} from "./doctor-core-checks.js";
import {
clearHealthChecksForTest,
listHealthChecks,
registerHealthCheck,
} from "./health-check-registry.js";
describe("registerCoreHealthChecks", () => {
let tmp: string | undefined;
beforeEach(() => {
clearHealthChecksForTest();
resetCoreHealthChecksForTest();
});
afterEach(async () => {
if (tmp !== undefined) {
await fs.rm(tmp, { recursive: true, force: true });
tmp = undefined;
}
});
it("registers the built-in health checks once", () => {
registerCoreHealthChecks();
registerCoreHealthChecks();
expect(listHealthChecks().map((check) => check.id)).toEqual([
"core/doctor/gateway-config",
"core/doctor/command-owner",
"core/doctor/workspace-status",
"core/doctor/skills-readiness",
"core/doctor/final-config-validation",
]);
});
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(5);
});
it("shows the repair-capable health check shape with skills readiness", async () => {
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-skills-"));
const skillDir = join(tmp, "skills", "missing-tool");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
join(skillDir, "SKILL.md"),
`---
name: missing-tool
description: Missing tool
metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}}'
---
# Missing tool
`,
"utf-8",
);
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmp,
skills: ["missing-tool"],
},
},
};
const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === "core/doctor/skills-readiness");
expect(check?.repair).toBeTypeOf("function");
const findings = await check?.detect({
mode: "lint",
runtime: { log() {}, error() {}, exit() {} },
cfg,
cwd: tmp,
});
expect(findings).toContainEqual(
expect.objectContaining({
checkId: "core/doctor/skills-readiness",
severity: "warning",
path: "skills.entries.missing-tool.enabled",
}),
);
await expect(
check?.detect(
{
mode: "fix",
runtime: { log() {}, error() {}, exit() {} },
cfg,
cwd: tmp,
},
{ paths: ["skills.entries.other-tool.enabled"] },
),
).resolves.toEqual([]);
await expect(
check?.detect(
{
mode: "fix",
runtime: { log() {}, error() {}, exit() {} },
cfg,
cwd: tmp,
},
{ paths: ["skills.entries.missing-tool.enabled"] },
),
).resolves.toContainEqual(
expect.objectContaining({
path: "skills.entries.missing-tool.enabled",
}),
);
const repaired = await check?.repair?.(
{
mode: "fix",
runtime: { log() {}, error() {}, exit() {} },
cfg,
cwd: tmp,
},
findings ?? [],
);
expect(repaired?.config?.skills?.entries?.["missing-tool"]).toEqual({ enabled: false });
expect(repaired?.changes).toContain("Disabled unavailable skill missing-tool.");
expect(repaired?.effects).toContainEqual(
expect.objectContaining({
kind: "config",
action: "disable-skill",
target: "skills.entries.missing-tool.enabled",
}),
);
});
});

View File

@@ -0,0 +1,239 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js";
import { hasConfiguredCommandOwners } from "../commands/doctor-command-owner.js";
import {
collectUnavailableAgentSkills,
disableUnavailableSkillsInConfig,
} from "../commands/doctor-skills.js";
import type { ConfigValidationIssue, OpenClawConfig } from "../config/types.openclaw.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { registerHealthCheck } from "./health-check-registry.js";
import type { HealthCheck, HealthFinding } from "./health-checks.js";
const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation";
export function configValidationIssuesToHealthFindings(
issues: readonly ConfigValidationIssue[],
): readonly HealthFinding[] {
return issues.map(
(issue): HealthFinding => ({
checkId: FINAL_CONFIG_VALIDATION_CHECK_ID,
severity: "error",
message: issue.message,
path: issue.path || "<root>",
}),
);
}
const gatewayConfigCheck: HealthCheck = {
id: "core/doctor/gateway-config",
kind: "core",
description: "openclaw.jsonc gateway block is set and unambiguous.",
source: "doctor",
async detect(ctx) {
const findings: HealthFinding[] = [];
if (!ctx.cfg.gateway?.mode) {
findings.push({
checkId: "core/doctor/gateway-config",
severity: "warning",
message: "gateway.mode is unset; gateway start will be blocked.",
path: "gateway.mode",
fixHint:
"Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`.",
});
}
if (ctx.cfg.gateway?.mode !== "remote" && hasAmbiguousGatewayAuthModeConfig(ctx.cfg)) {
findings.push({
checkId: "core/doctor/gateway-config",
severity: "warning",
message:
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset; auth selection is ambiguous.",
path: "gateway.auth.mode",
fixHint:
"Set an explicit mode: `openclaw config set gateway.auth.mode token` or `... password`.",
});
}
return findings;
},
};
const commandOwnerCheck: HealthCheck = {
id: "core/doctor/command-owner",
kind: "core",
description: "An owner account is configured for owner-only commands.",
source: "doctor",
async detect(ctx) {
if (hasConfiguredCommandOwners(ctx.cfg)) {
return [];
}
return [
{
checkId: "core/doctor/command-owner",
severity: "info",
message:
"No command owner is configured. Owner-only commands (/diagnostics, /export-trajectory, /config, exec approvals) have no allowed sender.",
path: "commands.ownerAllowFrom",
fixHint:
"Set commands.ownerAllowFrom to your channel user id, e.g. `openclaw config set commands.ownerAllowFrom '[\"telegram:123456789\"]'`.",
},
];
},
};
const workspaceStatusCheck: HealthCheck = {
id: "core/doctor/workspace-status",
kind: "core",
description: "Workspace directory exists and has no legacy duplicates.",
source: "doctor",
async detect(ctx) {
const { detectLegacyWorkspaceDirs } = await import("../commands/doctor-workspace.js");
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
const legacy = detectLegacyWorkspaceDirs({ workspaceDir });
if (legacy.legacyDirs.length === 0) {
return [];
}
return [
{
checkId: "core/doctor/workspace-status",
severity: "info",
message: `Detected ${legacy.legacyDirs.length} legacy workspace director${
legacy.legacyDirs.length === 1 ? "y" : "ies"
} alongside the active workspace.`,
path: workspaceDir,
fixHint:
"Inspect the legacy directories and migrate or remove them; see `openclaw doctor` for the detailed migration prompt.",
},
];
},
};
const skillsReadinessCheck: HealthCheck = {
id: "core/doctor/skills-readiness",
kind: "core",
description: "Allowed skills are usable in the current runtime environment.",
source: "doctor",
async detect(ctx, scope) {
const unavailable = filterUnavailableSkillsForScope(
detectUnavailableSkills(ctx.cfg),
scope?.paths,
);
return unavailable.map(unavailableSkillToFinding);
},
async repair(ctx, findings) {
const unavailable = filterUnavailableSkillsForScope(
detectUnavailableSkills(ctx.cfg),
findings.map((finding) => finding.path),
);
if (unavailable.length === 0) {
return { changes: [] };
}
const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable);
return {
config: nextConfig,
changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`),
effects: unavailable.map((skill) => ({
kind: "config" as const,
action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill",
target: skillReadinessPath(skill),
dryRunSafe: true,
})),
};
},
};
function unavailableSkillToFinding(skill: SkillStatusEntry): HealthFinding {
return {
checkId: "core/doctor/skills-readiness",
severity: "warning",
message: `${skill.name} is allowed but unavailable: ${formatMissingSkillSummary(skill)}.`,
path: skillReadinessPath(skill),
fixHint:
"Install/configure the missing requirement, or run `openclaw doctor --fix` to disable unused unavailable skills.",
};
}
function filterUnavailableSkillsForScope(
unavailable: readonly SkillStatusEntry[],
paths: readonly (string | undefined)[] | undefined,
): SkillStatusEntry[] {
const scopedPaths = new Set(paths?.filter((path): path is string => path !== undefined) ?? []);
if (scopedPaths.size === 0) {
return [...unavailable];
}
return unavailable.filter((skill) => scopedPaths.has(skillReadinessPath(skill)));
}
function skillReadinessPath(skill: SkillStatusEntry): string {
return `skills.entries.${skill.skillKey}.enabled`;
}
const finalConfigValidationCheck: HealthCheck = {
id: FINAL_CONFIG_VALIDATION_CHECK_ID,
kind: "core",
description: "Active openclaw.jsonc parses and conforms to the config schema.",
source: "doctor",
async detect() {
const { readConfigFileSnapshot } = await import("../config/config.js");
const snap = await readConfigFileSnapshot();
if (!snap.exists || snap.valid) {
return [];
}
return configValidationIssuesToHealthFindings(snap.issues);
},
};
let registered = false;
export function registerCoreHealthChecks(): void {
if (registered) {
return;
}
registerHealthCheck(gatewayConfigCheck);
registerHealthCheck(commandOwnerCheck);
registerHealthCheck(workspaceStatusCheck);
registerHealthCheck(skillsReadinessCheck);
registerHealthCheck(finalConfigValidationCheck);
registered = true;
}
export function resetCoreHealthChecksForTest(): void {
registered = false;
}
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = [
gatewayConfigCheck,
commandOwnerCheck,
workspaceStatusCheck,
skillsReadinessCheck,
finalConfigValidationCheck,
];
function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] {
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
agentId,
});
return collectUnavailableAgentSkills(report);
}
function formatMissingSkillSummary(skill: SkillStatusEntry): string {
const missing: string[] = [];
if (skill.missing.bins.length > 0) {
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
}
if (skill.missing.anyBins.length > 0) {
missing.push(`any bins: ${skill.missing.anyBins.join(", ")}`);
}
if (skill.missing.env.length > 0) {
missing.push(`env: ${skill.missing.env.join(", ")}`);
}
if (skill.missing.config.length > 0) {
missing.push(`config: ${skill.missing.config.join(", ")}`);
}
if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`);
}
return missing.join("; ") || "unknown requirement";
}

View File

@@ -0,0 +1,16 @@
const ERR_MESSAGE_MAX_LEN = 256;
export function scrubDoctorErrorMessage(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
let stripped = "";
for (let index = 0; index < raw.length; index++) {
const code = raw.charCodeAt(index);
if (code > 0x1f && code !== 0x7f) {
stripped += raw.charAt(index);
}
}
if (stripped.length <= ERR_MESSAGE_MAX_LEN) {
return stripped;
}
return `${stripped.slice(0, ERR_MESSAGE_MAX_LEN - 3)}...`;
}

View File

@@ -170,6 +170,18 @@ describe("doctor health contributions", () => {
expect(ids.indexOf("doctor:skills")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("runs structured repairs before legacy skill repairs and config writes", () => {
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
expect(ids.indexOf("doctor:structured-health-repairs")).toBeGreaterThan(-1);
expect(ids.indexOf("doctor:structured-health-repairs")).toBeLessThan(
ids.indexOf("doctor:skills"),
);
expect(ids.indexOf("doctor:structured-health-repairs")).toBeLessThan(
ids.indexOf("doctor:write-config"),
);
});
it("skips doctor config writes under legacy update parents", () => {
expect(
shouldSkipLegacyUpdateDoctorConfigWrite({

View File

@@ -228,6 +228,34 @@ async function runCommandOwnerHealth(ctx: DoctorHealthFlowContext): Promise<void
noteCommandOwnerHealth(ctx.cfg);
}
async function runStructuredHealthRepairs(ctx: DoctorHealthFlowContext): Promise<void> {
if (!ctx.prompter.shouldRepair) {
return;
}
const { registerCoreHealthChecks } = 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("../terminal/note.js");
registerCoreHealthChecks();
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,
});
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 runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteClaudeCliHealth } = await import("../commands/doctor-claude-cli.js");
noteClaudeCliHealth(ctx.cfg);
@@ -709,6 +737,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Command owner",
run: runCommandOwnerHealth,
}),
createDoctorHealthContribution({
id: "doctor:structured-health-repairs",
label: "Structured health repairs",
run: runStructuredHealthRepairs,
}),
createDoctorHealthContribution({
id: "doctor:legacy-state",
label: "Legacy state",

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { exitCodeFromFindings, runDoctorLintChecks } from "./doctor-lint-flow.js";
import type { HealthCheck, HealthCheckContext } from "./health-checks.js";
const ctx: HealthCheckContext = {
mode: "lint",
runtime: {
log() {},
error() {},
exit() {},
},
cfg: {},
};
function check(id: string, detect: HealthCheck["detect"]): HealthCheck {
return {
id,
kind: "core",
description: id,
detect,
};
}
describe("runDoctorLintChecks", () => {
it("filters selected checks and reports skipped count", async () => {
const result = await runDoctorLintChecks(ctx, {
checks: [
check("a", async () => [{ checkId: "a", severity: "warning", message: "warn" }]),
check("b", async () => [{ checkId: "b", severity: "error", message: "err" }]),
],
onlyIds: ["a"],
});
expect(result.checksRun).toBe(1);
expect(result.checksSkipped).toBe(1);
expect(result.findings.map((finding) => finding.checkId)).toEqual(["a"]);
});
it("turns thrown checks into error findings", async () => {
const result = await runDoctorLintChecks(ctx, {
checks: [
check("boom", async () => {
throw new Error("nope");
}),
],
});
expect(result.findings).toEqual([
{
checkId: "boom",
severity: "error",
message: "health check threw: nope",
},
]);
});
});
describe("exitCodeFromFindings", () => {
it("uses the selected severity threshold", () => {
const findings = [{ checkId: "a", severity: "warning" as const, message: "warn" }];
expect(exitCodeFromFindings(findings, "warning")).toBe(1);
expect(exitCodeFromFindings(findings, "error")).toBe(0);
});
});

View File

@@ -0,0 +1,85 @@
import { listHealthChecks } from "./health-check-registry.js";
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
import {
HEALTH_FINDING_SEVERITY_RANK,
healthFindingMeetsSeverity,
type HealthCheck,
type HealthCheckContext,
type HealthFinding,
type HealthFindingSeverity,
} from "./health-checks.js";
export interface DoctorLintRunOptions {
readonly checks?: readonly HealthCheck[];
readonly skipIds?: ReadonlySet<string> | readonly string[];
readonly onlyIds?: ReadonlySet<string> | readonly string[];
}
export interface DoctorLintRunResult {
readonly findings: readonly HealthFinding[];
readonly checksRun: number;
readonly checksSkipped: number;
}
export async function runDoctorLintChecks(
ctx: HealthCheckContext,
opts: DoctorLintRunOptions = {},
): Promise<DoctorLintRunResult> {
const all = opts.checks ?? listHealthChecks();
const skip = opts.skipIds instanceof Set ? opts.skipIds : new Set(opts.skipIds ?? []);
const only = opts.onlyIds instanceof Set ? opts.onlyIds : new Set(opts.onlyIds ?? []);
const selected = all.filter((c) => {
if (only.size > 0 && !only.has(c.id)) {
return false;
}
if (skip.has(c.id)) {
return false;
}
return true;
});
const findings: HealthFinding[] = [];
for (const check of selected) {
try {
const out = await check.detect(ctx);
for (const f of out) {
findings.push(f);
}
} catch (err) {
findings.push({
checkId: check.id,
severity: "error",
message: `health check threw: ${scrubDoctorErrorMessage(err)}`,
});
}
}
findings.sort(compareFindings);
return {
findings,
checksRun: selected.length,
checksSkipped: all.length - selected.length,
};
}
function compareFindings(a: HealthFinding, b: HealthFinding): number {
const sevDelta =
HEALTH_FINDING_SEVERITY_RANK[b.severity] - HEALTH_FINDING_SEVERITY_RANK[a.severity];
if (sevDelta !== 0) {
return sevDelta;
}
const idDelta = a.checkId.localeCompare(b.checkId);
if (idDelta !== 0) {
return idDelta;
}
return (a.path ?? "").localeCompare(b.path ?? "");
}
export function exitCodeFromFindings(
findings: readonly HealthFinding[],
severityMin: HealthFindingSeverity = "warning",
): 0 | 1 {
return findings.some((f) => healthFindingMeetsSeverity(f, severityMin)) ? 1 : 0;
}

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runDoctorHealthRepairs } from "./doctor-repair-flow.js";
import type { HealthCheck, HealthRepairContext } from "./health-checks.js";
function ctx(cfg: OpenClawConfig): HealthRepairContext {
return {
mode: "fix",
runtime: {
log() {},
error() {},
exit() {},
},
cfg,
};
}
describe("runDoctorHealthRepairs", () => {
it("repairs modern checks and threads updated config", async () => {
const scopes: unknown[] = [];
const checks: HealthCheck[] = [
{
id: "test/repairable",
kind: "core",
description: "repairable",
async detect(ctx, scope) {
if (scope !== undefined) {
scopes.push(scope);
}
return ctx.cfg.gateway?.mode === "local"
? []
: [
{
checkId: "test/repairable",
severity: "warning",
message: "gateway mode missing",
path: "gateway.mode",
},
];
},
async repair(ctx) {
return {
config: { ...ctx.cfg, gateway: { ...ctx.cfg.gateway, mode: "local" } },
changes: ["Set gateway.mode to local."],
};
},
},
];
const result = await runDoctorHealthRepairs(ctx({}), { checks });
expect(result.config.gateway?.mode).toBe("local");
expect(result.changes).toEqual(["Set gateway.mode to local."]);
expect(result.checksRepaired).toBe(1);
expect(result.checksValidated).toBe(1);
expect(result.remainingFindings).toEqual([]);
expect(scopes).toMatchObject([{ paths: ["gateway.mode"] }]);
});
it("leaves non-repairable checks for legacy doctor behavior", async () => {
const checks: HealthCheck[] = [
{
id: "test/legacy-only",
kind: "core",
description: "legacy only",
async detect() {
return [
{
checkId: "test/legacy-only",
severity: "warning",
message: "legacy repair still owns this finding",
},
];
},
},
];
const result = await runDoctorHealthRepairs(ctx({}), { checks });
expect(result.config).toEqual({});
expect(result.findings).toHaveLength(1);
expect(result.remainingFindings).toEqual([]);
expect(result.changes).toEqual([]);
expect(result.checksRepaired).toBe(0);
expect(result.checksValidated).toBe(0);
});
it("reports repair validation findings that remain after repair", async () => {
const checks: HealthCheck[] = [
{
id: "test/not-fixed",
kind: "core",
description: "not fixed",
async detect() {
return [
{
checkId: "test/not-fixed",
severity: "warning",
message: "still broken",
ocPath: "oc://openclaw.json/gateway.mode",
},
];
},
async repair() {
return {
changes: ["Tried repair."],
};
},
},
];
const result = await runDoctorHealthRepairs(ctx({}), { checks });
expect(result.checksRepaired).toBe(1);
expect(result.checksValidated).toBe(1);
expect(result.remainingFindings).toMatchObject([
{
checkId: "test/not-fixed",
ocPath: "oc://openclaw.json/gateway.mode",
},
]);
expect(result.warnings).toEqual(["test/not-fixed repair left 1 finding(s)"]);
});
it("does not validate skipped or failed repair results", async () => {
let validationCalls = 0;
const checks: HealthCheck[] = [
{
id: "test/skipped",
kind: "core",
description: "skipped",
async detect() {
validationCalls++;
return [
{
checkId: "test/skipped",
severity: "warning",
message: "needs manual repair",
},
];
},
async repair() {
return {
status: "skipped",
reason: "manual confirmation required",
changes: [],
};
},
},
];
const result = await runDoctorHealthRepairs(ctx({}), { checks });
expect(validationCalls).toBe(1);
expect(result.checksRepaired).toBe(0);
expect(result.checksValidated).toBe(0);
expect(result.remainingFindings).toEqual([]);
expect(result.warnings).toEqual(["test/skipped repair skipped: manual confirmation required"]);
});
it("supports dry-run repairs without applying returned config or validating", async () => {
const repairContexts: HealthRepairContext[] = [];
let detectCalls = 0;
const checks: HealthCheck[] = [
{
id: "test/dry-run",
kind: "core",
description: "dry run",
async detect(ctx) {
detectCalls++;
return ctx.cfg.gateway?.mode === "local"
? []
: [
{
checkId: "test/dry-run",
severity: "warning",
message: "gateway mode missing",
path: "gateway.mode",
},
];
},
async repair(ctx) {
repairContexts.push(ctx);
return {
config: { ...ctx.cfg, gateway: { ...ctx.cfg.gateway, mode: "local" } },
changes: ["Would set gateway.mode to local."],
diffs: [
{
kind: "config",
path: "gateway.mode",
before: undefined,
after: "local",
},
],
effects: [
{
kind: "config",
action: "would-set",
target: "gateway.mode",
dryRunSafe: true,
},
],
};
},
},
];
const result = await runDoctorHealthRepairs(ctx({}), {
checks,
dryRun: true,
diff: true,
});
expect(result.config).toEqual({});
expect(result.changes).toEqual(["Would set gateway.mode to local."]);
expect(result.diffs).toMatchObject([{ kind: "config", path: "gateway.mode" }]);
expect(result.effects).toMatchObject([{ kind: "config", action: "would-set" }]);
expect(result.checksRepaired).toBe(1);
expect(result.checksValidated).toBe(0);
expect(detectCalls).toBe(1);
expect(repairContexts[0]).toMatchObject({ dryRun: true, diff: true });
});
});

View File

@@ -0,0 +1,123 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
import { listHealthChecks } from "./health-check-registry.js";
import type {
HealthCheck,
HealthFinding,
HealthRepairContext,
HealthRepairDiff,
HealthRepairEffect,
} from "./health-checks.js";
export interface DoctorRepairRunOptions {
readonly checks?: readonly HealthCheck[];
readonly dryRun?: boolean;
readonly diff?: boolean;
}
export interface DoctorRepairRunResult {
readonly config: OpenClawConfig;
readonly findings: readonly HealthFinding[];
readonly remainingFindings: readonly HealthFinding[];
readonly changes: readonly string[];
readonly warnings: readonly string[];
readonly diffs: readonly HealthRepairDiff[];
readonly effects: readonly HealthRepairEffect[];
readonly checksRun: number;
readonly checksRepaired: number;
readonly checksValidated: number;
}
export async function runDoctorHealthRepairs(
ctx: HealthRepairContext,
opts: DoctorRepairRunOptions = {},
): Promise<DoctorRepairRunResult> {
const checks = opts.checks ?? listHealthChecks();
const findings: HealthFinding[] = [];
const remainingFindings: HealthFinding[] = [];
const changes: string[] = [];
const warnings: string[] = [];
const diffs: HealthRepairDiff[] = [];
const effects: HealthRepairEffect[] = [];
let cfg = ctx.cfg;
let checksRepaired = 0;
let checksValidated = 0;
for (const check of checks) {
const detectCtx: HealthRepairContext = { ...ctx, cfg };
let checkFindings: readonly HealthFinding[];
try {
checkFindings = await check.detect(detectCtx);
} catch (err) {
warnings.push(`${check.id} detect failed: ${scrubDoctorErrorMessage(err)}`);
continue;
}
findings.push(...checkFindings);
if (checkFindings.length === 0 || check.repair === undefined) {
continue;
}
try {
const result = await check.repair(
{ ...ctx, cfg, dryRun: opts.dryRun === true, diff: opts.diff === true },
checkFindings,
);
warnings.push(...(result.warnings ?? []));
diffs.push(...(result.diffs ?? []));
effects.push(...(result.effects ?? []));
const status = result.status ?? "repaired";
if (status !== "repaired") {
warnings.push(`${check.id} repair ${status}${result.reason ? `: ${result.reason}` : ""}`);
continue;
}
if (result.config !== undefined && opts.dryRun !== true) {
cfg = result.config;
}
changes.push(...result.changes);
checksRepaired++;
if (opts.dryRun === true) {
continue;
}
try {
const validationFindings = await check.detect(
{ ...ctx, cfg },
createValidationScope(checkFindings),
);
remainingFindings.push(...validationFindings);
checksValidated++;
if (validationFindings.length > 0) {
warnings.push(`${check.id} repair left ${validationFindings.length} finding(s)`);
}
} catch (err) {
warnings.push(`${check.id} validation failed: ${scrubDoctorErrorMessage(err)}`);
}
} catch (err) {
warnings.push(`${check.id} repair failed: ${scrubDoctorErrorMessage(err)}`);
}
}
return {
config: cfg,
findings,
remainingFindings,
changes,
warnings,
diffs,
effects,
checksRun: checks.length,
checksRepaired,
checksValidated,
};
}
function createValidationScope(findings: readonly HealthFinding[]) {
return {
findings,
paths: uniqueDefined(findings.map((finding) => finding.path)),
ocPaths: uniqueDefined(findings.map((finding) => finding.ocPath)),
};
}
function uniqueDefined(values: readonly (string | undefined)[]): readonly string[] {
return [...new Set(values.filter((value): value is string => value !== undefined))];
}

View File

@@ -0,0 +1,30 @@
import type { HealthCheck } from "./health-checks.js";
const REGISTRY = new Map<string, HealthCheck>();
export class HealthCheckRegistrationError extends Error {
readonly code = "OC_DOCTOR_DUPLICATE_CHECK";
constructor(readonly checkId: string) {
super(`health check already registered: ${checkId}`);
this.name = "HealthCheckRegistrationError";
}
}
export function registerHealthCheck(check: HealthCheck): void {
if (REGISTRY.has(check.id)) {
throw new HealthCheckRegistrationError(check.id);
}
REGISTRY.set(check.id, check);
}
export function listHealthChecks(): readonly HealthCheck[] {
return [...REGISTRY.values()];
}
export function getHealthCheck(id: string): HealthCheck | undefined {
return REGISTRY.get(id);
}
export function clearHealthChecksForTest(): void {
REGISTRY.clear();
}

View File

@@ -0,0 +1,99 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
export type HealthFindingSeverity = "info" | "warning" | "error";
export const HEALTH_FINDING_SEVERITY_RANK: Record<HealthFindingSeverity, number> = {
info: 0,
warning: 1,
error: 2,
};
export function parseHealthFindingSeverity(
input: string | undefined,
): HealthFindingSeverity | null {
if (input === "info" || input === "warning" || input === "error") {
return input;
}
return null;
}
export function healthFindingMeetsSeverity(
finding: Pick<HealthFinding, "severity">,
severityMin: HealthFindingSeverity,
): boolean {
return (
HEALTH_FINDING_SEVERITY_RANK[finding.severity] >= HEALTH_FINDING_SEVERITY_RANK[severityMin]
);
}
export interface HealthFinding {
readonly checkId: string;
readonly severity: HealthFindingSeverity;
readonly message: string;
readonly source?: string;
readonly path?: string;
readonly line?: number;
readonly column?: number;
readonly ocPath?: string;
readonly fixHint?: string;
}
export type HealthCheckMode = "doctor" | "lint" | "fix";
export interface HealthCheckContext {
readonly mode: HealthCheckMode;
readonly runtime: RuntimeEnv;
readonly cfg: OpenClawConfig;
readonly cwd?: string;
readonly configPath?: string;
}
export interface HealthRepairContext extends Omit<HealthCheckContext, "mode"> {
readonly mode: "fix";
readonly dryRun?: boolean;
readonly diff?: boolean;
}
export interface HealthRepairDiff {
readonly kind: "config" | "file";
readonly path: string;
readonly before?: string;
readonly after?: string;
readonly unifiedDiff?: string;
}
export interface HealthRepairEffect {
readonly kind: "config" | "file" | "service" | "process" | "package" | "state" | "other";
readonly action: string;
readonly target?: string;
readonly dryRunSafe?: boolean;
}
export interface HealthRepairResult {
readonly status?: "repaired" | "skipped" | "failed";
readonly reason?: string;
readonly config?: OpenClawConfig;
readonly changes: readonly string[];
readonly warnings?: readonly string[];
readonly diffs?: readonly HealthRepairDiff[];
readonly effects?: readonly HealthRepairEffect[];
}
export interface HealthCheckScope {
readonly findings?: readonly HealthFinding[];
readonly paths?: readonly string[];
readonly ocPaths?: readonly string[];
}
export interface HealthCheck {
readonly id: string;
readonly kind: "core" | "plugin";
readonly description: string;
readonly source?: string;
detect(ctx: HealthCheckContext, scope?: HealthCheckScope): Promise<readonly HealthFinding[]>;
repair?(
ctx: HealthRepairContext,
findings: readonly HealthFinding[],
): Promise<HealthRepairResult>;
}

View File

@@ -1,7 +1,7 @@
import net from "node:net";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
import { stripAnsi } from "../terminal/ansi.js";
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());

30
src/plugin-sdk/health.ts Normal file
View File

@@ -0,0 +1,30 @@
export { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
export { readConfigFileSnapshot } from "../config/config.js";
export type { OpenClawConfig } from "../config/types.openclaw.js";
export {
configValidationIssuesToHealthFindings,
registerCoreHealthChecks,
} from "../flows/doctor-core-checks.js";
export {
exitCodeFromFindings,
runDoctorLintChecks,
type DoctorLintRunOptions,
} from "../flows/doctor-lint-flow.js";
export {
healthFindingMeetsSeverity,
parseHealthFindingSeverity,
type HealthCheck,
type HealthCheckContext,
type HealthCheckScope,
type HealthFinding,
type HealthFindingSeverity,
type HealthRepairDiff,
type HealthRepairEffect,
type HealthRepairContext,
type HealthRepairResult,
} from "../flows/health-checks.js";
export {
getHealthCheck,
listHealthChecks,
registerHealthCheck,
} from "../flows/health-check-registry.js";