mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
c2cc95bf17
commit
32221cbcad
@@ -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, {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
clearConfigCache,
|
||||
getRuntimeConfig,
|
||||
loadConfig,
|
||||
preserveConfigSnapshotAsClobbered,
|
||||
promoteConfigSnapshotToLastKnownGood,
|
||||
readBestEffortConfig,
|
||||
readBestEffortConfigSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user