diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 607166bc626e..5d26dc91aa5b 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -373,6 +373,7 @@ compare restored legacy artifacts with the SQLite rows before importing. - `--lint` is stricter than `--non-interactive`: always read-only, never prompts, never applies safe migrations. Use `doctor --fix` or `doctor --repair` when you want doctor to make changes. - Doctor does not execute `exec` SecretRefs while checking secrets by default. Use `--allow-exec` (with or without `--lint`) only when you intentionally want doctor to run those configured secret resolvers. - Any config write (including a `--fix` repair) rotates a backup to `~/.openclaw/openclaw.json.bak` (with a numbered `.bak.1`..`.bak.4` ring). `--fix` also drops unknown config keys reported by schema validation, listing each removal; it skips this while an update is in progress so partially written upgrade state is not stripped before its migration finishes. +- If `openclaw.json` cannot be parsed and no last-known-good config can be recovered, `doctor --fix` preserves the original as `openclaw.json.clobbered.`, leaves the current file unchanged, and exits with an error instead of writing a partial replacement. - Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. - On Linux, doctor ignores inactive extra gateway-like systemd units and does not rewrite command/entrypoint metadata for a running systemd gateway service during repair. Stop the service first, or use `openclaw gateway install --force` to replace the active launcher. - `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` to replace the launcher. diff --git a/src/commands/doctor-config-preflight.test.ts b/src/commands/doctor-config-preflight.test.ts index bb0cb49a6f91..c4810bd6efea 100644 --- a/src/commands/doctor-config-preflight.test.ts +++ b/src/commands/doctor-config-preflight.test.ts @@ -1,5 +1,6 @@ // Doctor config preflight tests cover last-known-good snapshots and config snapshot promotion. import fs from "node:fs/promises"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot } from "../config/config.js"; import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; @@ -122,6 +123,36 @@ describe("runDoctorConfigPreflight", () => { }); }); + it("preserves and rejects unparseable config without last-known-good during repair preflight", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const brokenRaw = '{ "gateway": { "mode": "local" }, "models": {'; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, brokenRaw, "utf-8"); + + const failure = await runDoctorConfigPreflight({ + migrateState: false, + migrateLegacyConfig: false, + repairPrefixedConfig: true, + invalidConfigNote: false, + }).then( + () => null, + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("Config could not be parsed or recovered."); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(brokenRaw); + const entries = await fs.readdir(path.dirname(configPath)); + const clobbered = entries.filter((entry) => entry.startsWith("openclaw.json.clobbered.")); + expect(clobbered).toHaveLength(1); + const clobberedPath = path.join(path.dirname(configPath), clobbered[0] ?? "missing"); + expect((failure as Error).message).toContain(`Original preserved at ${clobberedPath}.`); + await expect(fs.readFile(clobberedPath, "utf-8")).resolves.toBe(brokenRaw); + }); + }); + it("does not restore last-known-good for stale plugins.deny entries", async () => { await withTempHome(async (home) => { const configPath = await writeOpenClawConfig(home, { diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index a693f08690d3..4ef1ba83256e 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { note } from "../../packages/terminal-core/src/note.js"; import { cloneEnvWithPlatformSemantics } from "../config/env-vars.js"; import { + parseConfigJson5, + preserveConfigSnapshotAsClobbered, readConfigFileSnapshot, recoverConfigFromJsonRootSuffix, recoverConfigFromLastKnownGood, @@ -341,6 +343,21 @@ export async function runDoctorConfigPreflight( ); snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions)); } + if ( + !snapshot.valid && + typeof snapshot.raw === "string" && + !parseConfigJson5(snapshot.raw).ok + ) { + const clobberedPath = await preserveConfigSnapshotAsClobbered(snapshot); + if (!clobberedPath) { + throw new Error( + `Config could not be parsed or recovered, and doctor could not preserve a .clobbered snapshot. The original remains unchanged at ${snapshot.path}; refusing to apply repairs.`, + ); + } + throw new Error( + `Config could not be parsed or recovered. Original preserved at ${clobberedPath}. The current file remains unchanged; refusing to apply repairs.`, + ); + } } const invalidConfigNote = options.invalidConfigNote ?? "Config invalid; doctor will run with best-effort config."; diff --git a/src/config/io.factory.ts b/src/config/io.factory.ts index d33a745b0112..60fb574c9e15 100644 --- a/src/config/io.factory.ts +++ b/src/config/io.factory.ts @@ -1,6 +1,7 @@ import { createConfigIoContext } from "./io.context.js"; import { loadConfigFromContext } from "./io.load.js"; import { + preserveConfigSnapshotAsClobbered, promoteConfigSnapshotToLastKnownGood, recoverConfigFromLastKnownGood, } from "./io.observe-recovery.js"; @@ -46,6 +47,8 @@ export function createConfigIO(options: ConfigIoFactoryOptions = {}) { snapshot: params.snapshot, reason: params.reason, }), + preserveConfigSnapshotAsClobbered: (snapshot: ConfigFileSnapshot) => + preserveConfigSnapshotAsClobbered({ deps: context.deps, snapshot }), recoverConfigFromJsonRootSuffix: (snapshot: ConfigFileSnapshot) => recoverConfigFromJsonRootSuffixWithContext(context, snapshot), writeConfigFile: ( diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index 84e24f117b4a..fae343b3b2a5 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -943,10 +943,9 @@ export async function recoverConfigFromLastKnownGood(params: { stat: stat as ConfigStatMetadataSource, observedAt: now, }); - const clobberedPath = await persistBoundedClobberedConfigSnapshot({ + const clobberedPath = await preserveConfigSnapshotAsClobbered({ deps, - configPath: snapshot.path, - raw: snapshot.raw, + snapshot, observedAt: now, }); await deps.fs.promises.writeFile(snapshot.path, backupRaw, { @@ -987,4 +986,20 @@ export async function recoverConfigFromLastKnownGood(params: { ); return true; } + +export async function preserveConfigSnapshotAsClobbered(params: { + deps: ObserveRecoveryDeps; + snapshot: ConfigFileSnapshot; + observedAt?: string; +}): Promise { + if (!params.snapshot.exists || typeof params.snapshot.raw !== "string") { + return null; + } + return await persistBoundedClobberedConfigSnapshot({ + deps: params.deps, + configPath: params.snapshot.path, + raw: params.snapshot.raw, + observedAt: params.observedAt ?? new Date().toISOString(), + }); +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/config/io.runtime.ts b/src/config/io.runtime.ts index cd9f1a3f0fbe..a962a3e60d29 100644 --- a/src/config/io.runtime.ts +++ b/src/config/io.runtime.ts @@ -186,6 +186,12 @@ export async function recoverConfigFromLastKnownGood(params: { return await createConfigIO().recoverConfigFromLastKnownGood(params); } +export async function preserveConfigSnapshotAsClobbered( + snapshot: ConfigFileSnapshot, +): Promise { + return await createConfigIO().preserveConfigSnapshotAsClobbered(snapshot); +} + export async function recoverConfigFromJsonRootSuffix( snapshot: ConfigFileSnapshot, ): Promise { diff --git a/src/config/io.ts b/src/config/io.ts index c25d169a1ca3..1ca78a17a899 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -9,6 +9,7 @@ export { clearConfigCache, getRuntimeConfig, loadConfig, + preserveConfigSnapshotAsClobbered, promoteConfigSnapshotToLastKnownGood, readBestEffortConfig, readBestEffortConfigSnapshot,