Skip to content

Commit 9a5f2f6

Browse files
authored
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
1 parent 0dc04fb commit 9a5f2f6

33 files changed

Lines changed: 1771 additions & 35 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
d979b8c2721eeb83380a38853309e9ba0f2c28e040a9ad2ee1e7b2ab10c547db plugin-sdk-api-baseline.json
2-
4815f711fe2481483159137cfb97ce3d1c173e0b50a364a2353f49888f4d53df plugin-sdk-api-baseline.jsonl
1+
048d8ff5e4455d16f75f6762a916f67c982e1211fb7085456647234255567466 plugin-sdk-api-baseline.json
2+
2d46a9660c9143f823a47df3c7ecfd315a4999e96af5eddb4ba4e71d9bb377a6 plugin-sdk-api-baseline.jsonl

docs/cli/doctor.md

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,34 @@ Related:
1515
- Troubleshooting: [Troubleshooting](/gateway/troubleshooting)
1616
- Security audit: [Security](/gateway/security)
1717

18+
## Why Use It
19+
20+
`openclaw doctor` is the OpenClaw health surface. Use it when the gateway,
21+
channels, plugins, skills, model routing, local state, or config migrations are
22+
not behaving as expected and you want one command that can explain what is
23+
wrong.
24+
25+
Doctor has three postures:
26+
27+
| Posture | Command | Behavior |
28+
| ------- | ------------------------ | ------------------------------------------------------------------------------- |
29+
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
30+
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
31+
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
32+
33+
Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a
34+
human operator intentionally wants doctor to edit config or state.
35+
1836
## Examples
1937

2038
```bash
2139
openclaw doctor
22-
openclaw doctor --repair
40+
openclaw doctor --lint
41+
openclaw doctor --lint --json
42+
openclaw doctor --lint --severity-min warning
2343
openclaw doctor --deep
24-
openclaw doctor --repair --non-interactive
44+
openclaw doctor --fix
45+
openclaw doctor --fix --non-interactive
2546
openclaw doctor --generate-gateway-token
2647
```
2748

@@ -44,13 +65,134 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
4465
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
4566
- `--generate-gateway-token`: generate and configure a gateway token
4667
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
68+
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
69+
- `--json`: with `--lint`, emit JSON findings instead of human output
70+
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
71+
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
72+
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
73+
74+
## Lint mode
75+
76+
`openclaw doctor --lint` is the read-only automation posture for doctor checks.
77+
It uses the structured health-check path, does not prompt, and does not repair
78+
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
79+
when you want machine-readable findings instead of guided repair prompts.
80+
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
81+
are only accepted with `--lint`.
82+
83+
```bash
84+
openclaw doctor --lint
85+
openclaw doctor --lint --severity-min warning
86+
openclaw doctor --lint --json
87+
openclaw doctor --lint --only core/doctor/gateway-config --json
88+
```
89+
90+
Human output is compact:
91+
92+
```text
93+
doctor --lint: ran 5 check(s), 1 finding(s)
94+
[warning] core/doctor/gateway-config gateway.mode - gateway.mode is unset; gateway start will be blocked.
95+
fix: Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`.
96+
```
97+
98+
JSON output is the scripting surface for lint runs:
99+
100+
```json
101+
{
102+
"ok": false,
103+
"checksRun": 5,
104+
"checksSkipped": 0,
105+
"findings": [
106+
{
107+
"checkId": "core/doctor/gateway-config",
108+
"severity": "warning",
109+
"message": "gateway.mode is unset; gateway start will be blocked.",
110+
"path": "gateway.mode",
111+
"fixHint": "Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`."
112+
}
113+
]
114+
}
115+
```
116+
117+
Exit behavior:
118+
119+
- `0`: no findings at or above the selected severity threshold
120+
- `1`: at least one finding meets the selected threshold
121+
- `2`: command/runtime failure before lint findings can be produced
122+
123+
`--severity-min` controls both visible findings and the exit threshold. For
124+
example, `openclaw doctor --lint --severity-min error` can print no findings and
125+
exit `0` even when lower-severity `info` or `warning` findings exist.
126+
127+
## Structured Health Checks
128+
129+
Modern doctor checks use a small structured contract:
130+
131+
```ts
132+
detect(ctx, scope?) -> HealthFinding[]
133+
repair?(ctx, findings) -> HealthRepairResult
134+
```
135+
136+
`detect()` powers `doctor --lint`. `repair()` is optional and is only considered
137+
by `doctor --fix` / `doctor --repair`. Checks that have not migrated to this
138+
shape continue to use the legacy doctor contribution flow.
139+
140+
The split is intentional: `detect()` owns diagnosis, while `repair()` owns
141+
reporting what it changed or would change. Repair contexts can carry
142+
`dryRun`/`diff` requests, and repair results can return structured `diffs` for
143+
config/file edits plus `effects` for service, process, package, state, or other
144+
side effects. That lets converted checks grow toward `doctor --fix --dry-run`
145+
and diff reporting without moving mutation planning into `detect()`.
146+
147+
`repair()` reports whether it attempted the requested repair with `status:
148+
"repaired" | "skipped" | "failed"`. Omitted status means `repaired`, so simple
149+
repair checks only need to return changes. When repair returns `skipped` or
150+
`failed`, doctor reports the reason and does not run validation for that check.
151+
152+
After a successful structured repair, doctor re-runs `detect()` with the
153+
repaired findings as scope. Checks can use selected findings, paths, or `ocPath`
154+
values for focused validation. If the finding is still present, doctor reports a
155+
repair warning instead of treating the change as silently complete.
156+
157+
A finding includes:
158+
159+
| Field | Purpose |
160+
| ----------------- | ------------------------------------------------------ |
161+
| `checkId` | Stable id for skip/only filters and CI allowlists. |
162+
| `severity` | `info`, `warning`, or `error`. |
163+
| `message` | Human-readable problem statement. |
164+
| `path` | Config, file, or logical path when available. |
165+
| `line` / `column` | Source location when available. |
166+
| `ocPath` | Precise `oc://` address when a check can point to one. |
167+
| `fixHint` | Suggested operator action or repair summary. |
168+
169+
This release registers the modernized core doctor checks on the structured
170+
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
171+
contract for bundled follow-up consumers, but plugin-backed checks only run
172+
after their owning package registers them in the active command path.
173+
174+
## Check Selection
175+
176+
Use `--only` and `--skip` when a workflow wants a focused gate:
177+
178+
```bash
179+
openclaw doctor --lint --only core/doctor/gateway-config --json
180+
openclaw doctor --lint --skip core/doctor/skills-readiness
181+
```
182+
183+
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`
184+
id is not registered, no check runs for that id; use the command's `checksRun`
185+
and `checksSkipped` fields to verify a focused gate is selecting the checks you
186+
expect.
47187

48188
Notes:
49189

50190
- 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).
51191
- 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.
52-
- 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.
192+
- 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.
193+
- `--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.
53194
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
195+
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
54196
- `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.
55197
- 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.
56198
- 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.

docs/gateway/doctor.md

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,28 @@ openclaw doctor
2626
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
2727

2828
</Tab>
29-
<Tab title="--repair">
29+
<Tab title="--fix">
3030
```bash
31-
openclaw doctor --repair
31+
openclaw doctor --fix
3232
```
3333

3434
Apply recommended repairs without prompting (repairs + restarts where safe).
3535

3636
</Tab>
37-
<Tab title="--repair --force">
37+
<Tab title="--lint">
3838
```bash
39-
openclaw doctor --repair --force
39+
openclaw doctor --lint
40+
openclaw doctor --lint --json
41+
```
42+
43+
Run structured health checks for CI or preflight automation. This mode is
44+
read-only: it does not prompt, repair, migrate config, restart services, or
45+
touch state.
46+
47+
</Tab>
48+
<Tab title="--fix --force">
49+
```bash
50+
openclaw doctor --fix --force
4051
```
4152

4253
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:
6677
cat ~/.openclaw/openclaw.json
6778
```
6879

80+
## Read-only lint mode
81+
82+
`openclaw doctor --lint` is the automation-friendly sibling of
83+
`openclaw doctor --fix`. Both use doctor health checks, but their posture is
84+
different:
85+
86+
| Mode | Prompts | Writes config/state | Output | Use it for |
87+
| ------------------------ | --------- | ----------------------- | ---------------------- | ------------------------------- |
88+
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
89+
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
90+
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
91+
92+
Modernized health checks may provide an optional `repair()` implementation.
93+
`doctor --fix` applies those repairs when they exist and continues to use the
94+
existing doctor repair flow for checks that have not migrated yet.
95+
The structured repair contract also separates repair reporting from detection:
96+
`detect()` reports current findings, while `repair()` can report changes,
97+
config/file diffs, and non-file side effects. That keeps the migration path open
98+
for future `doctor --fix --dry-run` and diff output without making lint checks
99+
plan mutations.
100+
101+
Examples:
102+
103+
```bash
104+
openclaw doctor --lint
105+
openclaw doctor --lint --severity-min warning
106+
openclaw doctor --lint --json
107+
openclaw doctor --lint --only core/doctor/gateway-config --json
108+
```
109+
110+
JSON output includes:
111+
112+
- `ok`: whether any visible finding met the selected severity threshold
113+
- `checksRun`: number of health checks executed
114+
- `checksSkipped`: checks skipped by `--only` or `--skip`
115+
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
116+
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
117+
118+
Exit codes:
119+
120+
- `0`: no findings at or above the selected threshold
121+
- `1`: one or more findings met the selected threshold
122+
- `2`: command/runtime failure before lint findings could be emitted
123+
124+
Use `--severity-min info|warning|error` to control both what is printed and what
125+
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
126+
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
127+
lint run active.
128+
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
129+
must be paired with `--lint`; regular doctor and repair runs reject them.
130+
69131
## What it does (summary)
70132

71133
<AccordionGroup>
@@ -471,8 +533,8 @@ That stages grounded durable candidates into the short-term dreaming store while
471533

472534
- `openclaw doctor` prompts before rewriting supervisor config.
473535
- `openclaw doctor --yes` accepts the default repair prompts.
474-
- `openclaw doctor --repair` applies recommended fixes without prompts.
475-
- `openclaw doctor --repair --force` overwrites custom supervisor configs.
536+
- `openclaw doctor --fix` applies recommended fixes without prompts (`--repair` is an alias).
537+
- `openclaw doctor --fix --force` overwrites custom supervisor configs.
476538
- `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.
477539
- 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.
478540
- 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.

extensions/qa-lab/src/providers/mock-openai/server.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,7 @@ function subagentFanoutTaskForProvider(
197197
worker: "alpha" | "beta",
198198
) {
199199
const marker = worker === "alpha" ? "ALPHA-OK" : "BETA-OK";
200-
const scope =
201-
providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
200+
const scope = providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
202201
return `Fanout worker ${worker}: inspect ${scope} and finish with exactly ${marker}.`;
203202
}
204203

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@
132132
"types": "./dist/plugin-sdk/runtime.d.ts",
133133
"default": "./dist/plugin-sdk/runtime.js"
134134
},
135+
"./plugin-sdk/health": {
136+
"types": "./dist/plugin-sdk/health.d.ts",
137+
"default": "./dist/plugin-sdk/health.js"
138+
},
135139
"./plugin-sdk/runtime-doctor": {
136140
"types": "./dist/plugin-sdk/runtime-doctor.d.ts",
137141
"default": "./dist/plugin-sdk/runtime-doctor.js"

scripts/check-changed.mjs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { performance } from "node:perf_hooks";
21
import { accessSync, constants } from "node:fs";
32
import path from "node:path";
3+
import { performance } from "node:perf_hooks";
44
import {
55
detectChangedLanesForPaths,
66
listChangedPathsFromGit,
@@ -66,12 +66,9 @@ function executableExistsOnPath(command, env = process.env) {
6666
export function shouldSkipAppLintForMissingSwiftlint(options = {}) {
6767
const env = options.env ?? process.env;
6868
const platform = options.platform ?? process.platform;
69-
const swiftlintAvailable =
70-
options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
69+
const swiftlintAvailable = options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
7170
return (
72-
isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN) &&
73-
platform !== "darwin" &&
74-
!swiftlintAvailable
71+
isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN) && platform !== "darwin" && !swiftlintAvailable
7572
);
7673
}
7774

scripts/lib/plugin-sdk-doc-metadata.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const pluginSdkDocMetadata = {
1717
core: {
1818
category: "core",
1919
},
20+
health: {
21+
category: "core",
22+
},
2023
"approval-runtime": {
2124
category: "runtime",
2225
},

scripts/lib/plugin-sdk-entrypoints.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"self-hosted-provider-setup",
99
"routing",
1010
"runtime",
11+
"health",
1112
"runtime-doctor",
1213
"runtime-env",
1314
"runtime-logger",

src/cli/command-catalog.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
269269
{ commandPath: ["dashboard"], policy: { networkProxy: "bypass" } },
270270
{ commandPath: ["daemon"], policy: { networkProxy: "bypass" } },
271271
{ commandPath: ["devices"], policy: { networkProxy: "bypass" } },
272-
{ commandPath: ["doctor"], policy: { bypassConfigGuard: true } },
272+
{
273+
commandPath: ["doctor"],
274+
policy: {
275+
bypassConfigGuard: true,
276+
loadPlugins: "never",
277+
},
278+
},
273279
{ commandPath: ["exec-policy"], policy: { networkProxy: "bypass" } },
274280
{ commandPath: ["hooks"], policy: { networkProxy: "bypass" } },
275281
{ commandPath: ["logs"], policy: { networkProxy: "bypass" } },

src/cli/command-path-policy.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ describe("command-path-policy", () => {
198198
loadPlugins: "never",
199199
networkProxy: "bypass",
200200
});
201+
expectResolvedPolicy(["doctor"], {
202+
bypassConfigGuard: true,
203+
loadPlugins: "never",
204+
});
201205
expectResolvedPolicy(["config", "validate"], {
202206
bypassConfigGuard: true,
203207
loadPlugins: "never",

0 commit comments

Comments
 (0)