mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(config): recover prefixed config JSON
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
readSourceConfigSnapshot,
|
||||
readSourceConfigSnapshotForWrite,
|
||||
recoverConfigFromLastKnownGood,
|
||||
recoverConfigFromJsonRootSuffix,
|
||||
resetConfigRuntimeState,
|
||||
resolveConfigSnapshotHash,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
|
||||
101
src/config/io.ts
101
src/config/io.ts
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user