fix(doctor): preserve unparseable config instead of silently discarding it (#109362)

When a home's gateway had never started (no last-known-good promotion record),
`doctor --fix` on an unparseable openclaw.json declined recovery and wrote a
stripped best-effort config — silently dropping the user's gateway/models
sections — then exited 0. Preserve the original as `.clobbered.*` and refuse to
apply repairs (exit 1 with the preserved path) when the config is unparseable
and unrecoverable. Shared preserveConfigSnapshotAsClobbered helper; the
promoted-recovery path is unchanged.
This commit is contained in:
Peter Steinberger
2026-07-16 21:38:21 -07:00
committed by GitHub
parent c2cc95bf17
commit 32221cbcad
7 changed files with 77 additions and 3 deletions
+1
View File
@@ -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.<timestamp>`, 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.
@@ -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, {
+17
View File
@@ -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.";
+3
View File
@@ -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: (
+18 -3
View File
@@ -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<string | null> {
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. */
+6
View File
@@ -186,6 +186,12 @@ export async function recoverConfigFromLastKnownGood(params: {
return await createConfigIO().recoverConfigFromLastKnownGood(params);
}
export async function preserveConfigSnapshotAsClobbered(
snapshot: ConfigFileSnapshot,
): Promise<string | null> {
return await createConfigIO().preserveConfigSnapshotAsClobbered(snapshot);
}
export async function recoverConfigFromJsonRootSuffix(
snapshot: ConfigFileSnapshot,
): Promise<boolean> {
+1
View File
@@ -9,6 +9,7 @@ export {
clearConfigCache,
getRuntimeConfig,
loadConfig,
preserveConfigSnapshotAsClobbered,
promoteConfigSnapshotToLastKnownGood,
readBestEffortConfig,
readBestEffortConfigSnapshot,