fix(config): recover prefixed config JSON

This commit is contained in:
Peter Steinberger
2026-04-22 22:16:33 +01:00
parent 77dbc1cda6
commit 5d50b0c48f
10 changed files with 219 additions and 3 deletions

View File

@@ -100,6 +100,10 @@ The Gateway also keeps a trusted last-known-good copy after a successful startup
`openclaw.json` is later changed outside OpenClaw and no longer validates, startup
and hot reload preserve the broken file as a timestamped `.clobbered.*` snapshot,
restore the last-known-good copy, and log a loud warning with the recovery reason.
If a status/log line is accidentally prepended before an otherwise valid JSON
config, gateway startup and `openclaw doctor --fix` can strip the prefix,
preserve the polluted file as `.clobbered.*`, and continue with the recovered
JSON.
The next main-agent turn also receives a system-event warning telling it that the
config was restored and must not be blindly rewritten. Last-known-good promotion
is updated after validated startup and after accepted hot reloads, including

View File

@@ -28,6 +28,9 @@ const configState = vi.hoisted(() => ({
const recoverConfigFromLastKnownGood = vi.fn<(params?: unknown) => Promise<boolean>>(
async (_params?: unknown) => false,
);
const recoverConfigFromJsonRootSuffix = vi.fn<(snapshot?: unknown) => Promise<boolean>>(
async (_snapshot?: unknown) => false,
);
const writeRestartSentinel = vi.fn<(payload?: unknown) => Promise<string>>(
async (_payload?: unknown) => "/tmp/restart-sentinel.json",
);
@@ -42,6 +45,7 @@ vi.mock("../../config/config.js", () => ({
readBestEffortConfig: async () => configState.cfg,
readConfigFileSnapshot: async () => configState.snapshot,
recoverConfigFromLastKnownGood: (params: unknown) => recoverConfigFromLastKnownGood(params),
recoverConfigFromJsonRootSuffix: (snapshot: unknown) => recoverConfigFromJsonRootSuffix(snapshot),
resolveStateDir: () => "/tmp",
resolveGatewayPort: (cfg?: { gateway?: { port?: number } }) => cfg?.gateway?.port ?? 18789,
}));
@@ -159,6 +163,8 @@ describe("gateway run option collisions", () => {
gatewayLogMessages.length = 0;
recoverConfigFromLastKnownGood.mockReset();
recoverConfigFromLastKnownGood.mockResolvedValue(false);
recoverConfigFromJsonRootSuffix.mockReset();
recoverConfigFromJsonRootSuffix.mockResolvedValue(false);
writeRestartSentinel.mockReset();
writeRestartSentinel.mockResolvedValue("/tmp/restart-sentinel.json");
startGatewayServer.mockClear();

View File

@@ -13,6 +13,7 @@ import {
readBestEffortConfig,
readConfigFileSnapshot,
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
resolveStateDir,
resolveGatewayPort,
} from "../../config/config.js";
@@ -288,6 +289,18 @@ async function readGatewayStartupConfig(params: {
snapshot = await params.startupTrace.measure("cli.config-snapshot-reload", () =>
readConfigFileSnapshot().catch(() => null),
);
} else {
const repaired = await params.startupTrace.measure("cli.config-prefix-recovery", () =>
recoverConfigFromJsonRootSuffix(invalidSnapshot),
);
if (repaired) {
gatewayLog.warn(
`gateway: repaired invalid effective config by stripping a non-JSON prefix: ${invalidSnapshot.path}`,
);
snapshot = await params.startupTrace.measure("cli.config-snapshot-reload", () =>
readConfigFileSnapshot().catch(() => null),
);
}
}
}
if (snapshot?.valid) {

View File

@@ -44,7 +44,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
prompter?: DoctorPrompter;
}) {
const shouldRepair = params.options.repair === true || params.options.yes === true;
const preflight = await runDoctorConfigPreflight();
const preflight = await runDoctorConfigPreflight({ repairPrefixedConfig: shouldRepair });
let snapshot = preflight.snapshot;
const baseCfg = preflight.baseConfig;
let cfg: OpenClawConfig = baseCfg;

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { readConfigFileSnapshot } from "../config/io.js";
import { readConfigFileSnapshot, recoverConfigFromJsonRootSuffix } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { note } from "../terminal/note.js";
@@ -59,6 +59,7 @@ export async function runDoctorConfigPreflight(
options: {
migrateState?: boolean;
migrateLegacyConfig?: boolean;
repairPrefixedConfig?: boolean;
invalidConfigNote?: string | false;
} = {},
): Promise<DoctorConfigPreflightResult> {
@@ -80,7 +81,16 @@ export async function runDoctorConfigPreflight(
}
}
const snapshot = await readConfigFileSnapshot();
let snapshot = await readConfigFileSnapshot();
if (
options.repairPrefixedConfig === true &&
snapshot.exists &&
!snapshot.valid &&
(await recoverConfigFromJsonRootSuffix(snapshot))
) {
note("Removed non-JSON prefix from openclaw.json; original saved as .clobbered.*.", "Config");
snapshot = await readConfigFileSnapshot();
}
const invalidConfigNote =
options.invalidConfigNote ?? "Config invalid; doctor will run with best-effort config.";
if (

View File

@@ -18,6 +18,7 @@ export {
readSourceConfigSnapshot,
readSourceConfigSnapshotForWrite,
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
resetConfigRuntimeState,
resolveConfigSnapshotHash,
setRuntimeConfigSnapshotRefreshHandler,

View File

@@ -932,6 +932,92 @@ export function parseConfigJson5(
}
}
function findJsonRootSuffix(
raw: string,
json5: { parse: (value: string) => unknown } = JSON5,
): { raw: string; parsed: unknown } | null {
if (/^\s*(?:\{|\[)/.test(raw)) {
return null;
}
let offset = 0;
while (offset < raw.length) {
const nextNewline = raw.indexOf("\n", offset);
const lineEnd = nextNewline === -1 ? raw.length : nextNewline + 1;
const line = raw.slice(offset, lineEnd);
if (/^\s*(?:\{|\[)/.test(line)) {
const candidate = raw.slice(offset);
const parsed = parseConfigJson5(candidate, json5);
return parsed.ok ? { raw: candidate, parsed: parsed.parsed } : null;
}
offset = lineEnd;
}
return null;
}
async function persistPrefixedConfigRecovery(params: {
deps: Required<ConfigIoDeps>;
configPath: string;
originalRaw: string;
recoveredRaw: string;
}): Promise<void> {
const observedAt = new Date().toISOString();
const clobberedPath = await persistClobberedConfigSnapshot({
deps: params.deps,
configPath: params.configPath,
raw: params.originalRaw,
observedAt,
});
await params.deps.fs.promises.writeFile(params.configPath, params.recoveredRaw, {
encoding: "utf-8",
mode: 0o600,
});
await params.deps.fs.promises.chmod?.(params.configPath, 0o600).catch(() => {});
params.deps.logger.warn(
`Config auto-stripped non-JSON prefix: ${params.configPath}` +
(clobberedPath ? ` (original saved as ${clobberedPath})` : ""),
);
}
async function recoverConfigFromJsonRootSuffixWithDeps(params: {
deps: Required<ConfigIoDeps>;
configPath: string;
snapshot: ConfigFileSnapshot;
}): Promise<boolean> {
if (!params.snapshot.exists || params.snapshot.valid || typeof params.snapshot.raw !== "string") {
return false;
}
const suffixRecovery = findJsonRootSuffix(params.snapshot.raw, params.deps.json5);
if (!suffixRecovery) {
return false;
}
let resolved: unknown;
try {
resolved = resolveConfigIncludesForRead(suffixRecovery.parsed, params.configPath, params.deps);
} catch {
return false;
}
const readResolution = resolveConfigForRead(resolved, params.deps.env);
const legacyResolution = resolveLegacyConfigForRead(
readResolution.resolvedConfigRaw,
suffixRecovery.parsed,
);
const validated = validateConfigObjectWithPlugins(legacyResolution.effectiveConfigRaw, {
env: params.deps.env,
});
if (!validated.ok) {
return false;
}
await persistPrefixedConfigRecovery({
deps: params.deps,
configPath: params.configPath,
originalRaw: params.snapshot.raw,
recoveredRaw: suffixRecovery.raw,
});
return true;
}
type ConfigReadResolution = {
resolvedConfigRaw: unknown;
envSnapshotForRestore: Record<string, string | undefined>;
@@ -1446,6 +1532,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
});
}
async function recoverConfigFromJsonRootSuffix(snapshot: ConfigFileSnapshot): Promise<boolean> {
return await recoverConfigFromJsonRootSuffixWithDeps({
deps,
configPath,
snapshot,
});
}
async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
const result = await readConfigFileSnapshotInternal();
return {
@@ -1793,6 +1887,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
readConfigFileSnapshotForWrite,
promoteConfigSnapshotToLastKnownGood,
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
writeConfigFile,
};
}
@@ -1906,6 +2001,12 @@ export async function recoverConfigFromLastKnownGood(params: {
return await createConfigIO().recoverConfigFromLastKnownGood(params);
}
export async function recoverConfigFromJsonRootSuffix(
snapshot: ConfigFileSnapshot,
): Promise<boolean> {
return await createConfigIO().recoverConfigFromJsonRootSuffix(snapshot);
}
export async function readSourceConfigSnapshot(): Promise<ConfigFileSnapshot> {
return await readConfigFileSnapshot();
}

View File

@@ -234,6 +234,44 @@ describe("config io write", () => {
});
});
it("recovers configs polluted by a leading status line", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const cleanConfig = {
gateway: { mode: "local" },
agents: { list: [{ id: "main", default: true }, { id: "discord-dm" }] },
} satisfies ConfigFileSnapshot["config"];
const cleanRaw = `${JSON.stringify(cleanConfig, null, 2)}\n`;
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `Found and updated: False\n${cleanRaw}`, "utf-8");
const warn = vi.fn();
const io = createConfigIO({
env: { VITEST: "true" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: { warn, error: vi.fn() },
});
const initialSnapshot = await io.readConfigFileSnapshot();
expect(initialSnapshot.valid).toBe(false);
await expect(io.recoverConfigFromJsonRootSuffix(initialSnapshot)).resolves.toBe(true);
const recoveredSnapshot = await io.readConfigFileSnapshot();
expect(recoveredSnapshot.valid).toBe(true);
expect(recoveredSnapshot.config.gateway?.mode).toBe("local");
expect(recoveredSnapshot.config.agents?.list?.map((entry) => entry.id)).toEqual([
"main",
"discord-dm",
]);
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(cleanRaw);
const entries = await fs.readdir(path.dirname(configPath));
expect(entries.some((entry) => entry.includes(".clobbered."))).toBe(true);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Config auto-stripped non-JSON prefix:"),
);
});
});
it("rejects destructive internal writes before replacing the config", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -7,6 +7,7 @@ vi.mock("../config/config.js", () => ({
isNixMode: false,
readConfigFileSnapshot: vi.fn(),
recoverConfigFromLastKnownGood: vi.fn(),
recoverConfigFromJsonRootSuffix: vi.fn(),
writeConfigFile: vi.fn(),
}));
@@ -95,6 +96,7 @@ describe("gateway startup config recovery", () => {
const invalidSnapshot = buildSnapshot({ valid: false, raw: "{ invalid json" });
vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot);
vi.mocked(configIo.recoverConfigFromLastKnownGood).mockResolvedValueOnce(false);
vi.mocked(configIo.recoverConfigFromJsonRootSuffix).mockResolvedValueOnce(false);
await expect(
loadGatewayStartupConfigSnapshot({
@@ -107,4 +109,37 @@ describe("gateway startup config recovery", () => {
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled();
});
it("strips a valid JSON suffix when last-known-good recovery is unavailable", async () => {
const invalidSnapshot = buildSnapshot({
valid: false,
raw: `Found and updated: False\n${JSON.stringify(validConfig)}\n`,
});
const repairedSnapshot = buildSnapshot({
valid: true,
raw: `${JSON.stringify(validConfig)}\n`,
config: validConfig,
});
vi.mocked(configIo.readConfigFileSnapshot)
.mockResolvedValueOnce(invalidSnapshot)
.mockResolvedValueOnce(repairedSnapshot);
vi.mocked(configIo.recoverConfigFromLastKnownGood).mockResolvedValueOnce(false);
vi.mocked(configIo.recoverConfigFromJsonRootSuffix).mockResolvedValueOnce(true);
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: true,
log,
}),
).resolves.toEqual({
snapshot: repairedSnapshot,
wroteConfig: true,
});
expect(configIo.recoverConfigFromJsonRootSuffix).toHaveBeenCalledWith(invalidSnapshot);
expect(log.warn).toHaveBeenCalledWith(
`gateway: invalid config was repaired by stripping a non-JSON prefix: ${configPath}`,
);
});
});

View File

@@ -8,6 +8,7 @@ import {
isNixMode,
readConfigFileSnapshot,
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
writeConfigFile,
} from "../config/config.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
@@ -88,6 +89,13 @@ export async function loadGatewayStartupConfigSnapshot(params: {
});
}
}
if (!recovered && (await recoverConfigFromJsonRootSuffix(configSnapshot))) {
wroteConfig = true;
params.log.warn(
`gateway: invalid config was repaired by stripping a non-JSON prefix: ${configSnapshot.path}`,
);
configSnapshot = await readConfigFileSnapshot();
}
}
assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true });
}