From 9a5f2f61e76fcc66e5f5039236a7a0bd29bdf517 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sun, 17 May 2026 12:29:57 -0700 Subject: [PATCH] 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 --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/cli/doctor.md | 148 ++++++++++- docs/gateway/doctor.md | 74 +++++- .../src/providers/mock-openai/server.ts | 3 +- package.json | 4 + scripts/check-changed.mjs | 9 +- scripts/lib/plugin-sdk-doc-metadata.ts | 3 + scripts/lib/plugin-sdk-entrypoints.json | 1 + src/cli/command-catalog.ts | 8 +- src/cli/command-path-policy.test.ts | 4 + src/cli/program/preaction.test.ts | 18 +- src/cli/program/preaction.ts | 1 + src/cli/program/register.maintenance.test.ts | 59 ++++- src/cli/program/register.maintenance.ts | 59 +++++ src/cli/update-cli/update-command.ts | 5 +- src/commands/doctor-lint.test.ts | 115 +++++++++ src/commands/doctor-lint.ts | 138 ++++++++++ src/config/config.ts | 1 + src/config/io.best-effort.test.ts | 17 ++ src/config/io.ts | 28 +- src/flows/doctor-core-checks.test.ts | 150 +++++++++++ src/flows/doctor-core-checks.ts | 239 ++++++++++++++++++ src/flows/doctor-error-message.ts | 16 ++ src/flows/doctor-health-contributions.test.ts | 12 + src/flows/doctor-health-contributions.ts | 33 +++ src/flows/doctor-lint-flow.test.ts | 65 +++++ src/flows/doctor-lint-flow.ts | 85 +++++++ src/flows/doctor-repair-flow.test.ts | 223 ++++++++++++++++ src/flows/doctor-repair-flow.ts | 123 +++++++++ src/flows/health-check-registry.ts | 30 +++ src/flows/health-checks.ts | 99 ++++++++ src/infra/ports.test.ts | 2 +- src/plugin-sdk/health.ts | 30 +++ 33 files changed, 1771 insertions(+), 35 deletions(-) create mode 100644 src/commands/doctor-lint.test.ts create mode 100644 src/commands/doctor-lint.ts create mode 100644 src/flows/doctor-core-checks.test.ts create mode 100644 src/flows/doctor-core-checks.ts create mode 100644 src/flows/doctor-error-message.ts create mode 100644 src/flows/doctor-lint-flow.test.ts create mode 100644 src/flows/doctor-lint-flow.ts create mode 100644 src/flows/doctor-repair-flow.test.ts create mode 100644 src/flows/doctor-repair-flow.ts create mode 100644 src/flows/health-check-registry.ts create mode 100644 src/flows/health-checks.ts create mode 100644 src/plugin-sdk/health.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f33842fafd5..d9652e5d815 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 66441f2b76d..9e075b26e83 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -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 `: with `--lint`, drop findings below `info`, `warning`, or `error` +- `--skip `: with `--lint`, skip a check id; repeat to skip more than one +- `--only `: 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.` 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. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index cf37ed8719b..7d064b7bb8e 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -26,17 +26,28 @@ openclaw doctor Accept defaults without prompting (including restart/service/sandbox repair steps when applicable). - + ```bash - openclaw doctor --repair + openclaw doctor --fix ``` Apply recommended repairs without prompting (repairs + restarts where safe). - + ```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. + + + + ```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 ` for narrow preflight gates and +`--skip ` 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) @@ -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. diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 7035193b522..892e2978035 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -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}.`; } diff --git a/package.json b/package.json index 70d1beb6de9..fee1f0605cd 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 77db4d40875..d91003ef3ab 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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 ); } diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index f558b108fa6..826bea06590 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -17,6 +17,9 @@ export const pluginSdkDocMetadata = { core: { category: "core", }, + health: { + category: "core", + }, "approval-runtime": { category: "runtime", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 9cf7c4d0b66..ad7e19f8566 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -8,6 +8,7 @@ "self-hosted-provider-setup", "routing", "runtime", + "health", "runtime-doctor", "runtime-env", "runtime-logger", diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index bcfdf1d4410..225a2030337 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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" } }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 874c8b66fe2..48fc2d09a70 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -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", diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index d997075dbb8..9699da98988 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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>; }; } - )._lifeCycleHooks?.preAction; + )["_lifeCycleHooks"]?.preAction; preActionHook = hooks?.[0] ?? null; }); }); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index d0673c7e9dd..810d4404346 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -133,6 +133,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) commandPath, startupPolicy, allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath), + skipConfigGuard: shouldBypassConfigGuardForCommandPath(commandPath), }); }); } diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index f4f783b8c86..0f2ffafef31 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -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); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 9064f32fc54..e032c215d92 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -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 ", + "With --lint: drop findings below this severity (info|warning|error)", + ) + .option( + "--skip ", + "With --lint: skip a specific check id (repeatable)", + (v: string, prev: string[]) => [...prev, v], + [], + ) + .option( + "--only ", + "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) + ); +} diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index ec930f9c73b..e1675c187e4 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -2757,8 +2757,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { 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 = diff --git a/src/commands/doctor-lint.test.ts b/src/commands/doctor-lint.test.ts new file mode 100644 index 00000000000..ea9fdccaf52 --- /dev/null +++ b/src/commands/doctor-lint.test.ts @@ -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", + ); + }); +}); diff --git a/src/commands/doctor-lint.ts b/src/commands/doctor-lint.ts new file mode 100644 index 00000000000..b2bcc4e9b7b --- /dev/null +++ b/src/commands/doctor-lint.ts @@ -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 { + 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 || ""; + 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 { + 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 } : {}), + }; +} diff --git a/src/config/config.ts b/src/config/config.ts index b226754e1bc..712d470e3be 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -40,6 +40,7 @@ export type { RuntimeConfigSnapshotMetadata, } from "./runtime-snapshot.js"; export type { + ConfigSnapshotReadOptions, ConfigWriteNotification, ConfigWriteResult, ReadConfigFileSnapshotWithPluginMetadataResult, diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index d60a3069854..dd920aa7a39 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -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, { diff --git a/src/config/io.ts b/src/config/io.ts index 486638dcc51..aee755a1e3f 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -887,6 +887,14 @@ export type ConfigIoDeps = { logger?: Pick; measure?: ConfigSnapshotReadMeasure; suppressFutureVersionWarning?: boolean; + observe?: boolean; +}; + +export type ConfigSnapshotReadOptions = { + measure?: ConfigSnapshotReadMeasure; + observe?: boolean; + skipPluginValidation?: boolean; + preservedLegacyRootKeys?: readonly string[]; }; function warnOnConfigMiskeys(raw: unknown, logger: Pick): void { @@ -946,6 +954,7 @@ function normalizeDeps(overrides: ConfigIoDeps = {}): Required { 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, result: ReadConfigFileSnapshotInternalResult, ): Promise { - await observeConfigSnapshot(deps, result.snapshot); + if (deps.observe) { + await observeConfigSnapshot(deps, result.snapshot); + } return result; } @@ -2382,15 +2393,14 @@ export async function readSourceConfigBestEffort(): Promise { return await createConfigIO().readSourceConfigBestEffort(); } -export async function readConfigFileSnapshot(options?: { - measure?: ConfigSnapshotReadMeasure; - skipPluginValidation?: boolean; - preservedLegacyRootKeys?: readonly string[]; -}): Promise { +export async function readConfigFileSnapshot( + options: ConfigSnapshotReadOptions = {}, +): Promise { 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(); diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts new file mode 100644 index 00000000000..314f217407a --- /dev/null +++ b/src/flows/doctor-core-checks.test.ts @@ -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", + }), + ); + }); +}); diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts new file mode 100644 index 00000000000..275494f9894 --- /dev/null +++ b/src/flows/doctor-core-checks.ts @@ -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 || "", + }), + ); +} + +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"; +} diff --git a/src/flows/doctor-error-message.ts b/src/flows/doctor-error-message.ts new file mode 100644 index 00000000000..802eb75694d --- /dev/null +++ b/src/flows/doctor-error-message.ts @@ -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)}...`; +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index ce9000cd764..97b16c56768 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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({ diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index b88876638c0..f96ff0d0755 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -228,6 +228,34 @@ async function runCommandOwnerHealth(ctx: DoctorHealthFlowContext): Promise { + 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 { 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", diff --git a/src/flows/doctor-lint-flow.test.ts b/src/flows/doctor-lint-flow.test.ts new file mode 100644 index 00000000000..fd136438ca6 --- /dev/null +++ b/src/flows/doctor-lint-flow.test.ts @@ -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); + }); +}); diff --git a/src/flows/doctor-lint-flow.ts b/src/flows/doctor-lint-flow.ts new file mode 100644 index 00000000000..52c8f8c23b0 --- /dev/null +++ b/src/flows/doctor-lint-flow.ts @@ -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 | readonly string[]; + readonly onlyIds?: ReadonlySet | readonly string[]; +} + +export interface DoctorLintRunResult { + readonly findings: readonly HealthFinding[]; + readonly checksRun: number; + readonly checksSkipped: number; +} + +export async function runDoctorLintChecks( + ctx: HealthCheckContext, + opts: DoctorLintRunOptions = {}, +): Promise { + 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; +} diff --git a/src/flows/doctor-repair-flow.test.ts b/src/flows/doctor-repair-flow.test.ts new file mode 100644 index 00000000000..c91ae54b809 --- /dev/null +++ b/src/flows/doctor-repair-flow.test.ts @@ -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 }); + }); +}); diff --git a/src/flows/doctor-repair-flow.ts b/src/flows/doctor-repair-flow.ts new file mode 100644 index 00000000000..01d1fe54222 --- /dev/null +++ b/src/flows/doctor-repair-flow.ts @@ -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 { + 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))]; +} diff --git a/src/flows/health-check-registry.ts b/src/flows/health-check-registry.ts new file mode 100644 index 00000000000..2dab8be4e4e --- /dev/null +++ b/src/flows/health-check-registry.ts @@ -0,0 +1,30 @@ +import type { HealthCheck } from "./health-checks.js"; + +const REGISTRY = new Map(); + +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(); +} diff --git a/src/flows/health-checks.ts b/src/flows/health-checks.ts new file mode 100644 index 00000000000..b22924ae0f3 --- /dev/null +++ b/src/flows/health-checks.ts @@ -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 = { + 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, + 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 { + 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; + repair?( + ctx: HealthRepairContext, + findings: readonly HealthFinding[], + ): Promise; +} diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 04a78885b59..546b212b30f 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -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()); diff --git a/src/plugin-sdk/health.ts b/src/plugin-sdk/health.ts new file mode 100644 index 00000000000..8b9532e318f --- /dev/null +++ b/src/plugin-sdk/health.ts @@ -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";