diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 972ce45eb2f..f91e105ba71 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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 diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 61e4e5913b4..ffaeab97d5b 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -28,6 +28,9 @@ const configState = vi.hoisted(() => ({ const recoverConfigFromLastKnownGood = vi.fn<(params?: unknown) => Promise>( async (_params?: unknown) => false, ); +const recoverConfigFromJsonRootSuffix = vi.fn<(snapshot?: unknown) => Promise>( + async (_snapshot?: unknown) => false, +); const writeRestartSentinel = vi.fn<(payload?: unknown) => Promise>( 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(); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 3801b829b5c..f2280443c42 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -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) { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f1100a82b4b..4d3d83378c8 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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; diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 81dcc751c31..f4d0dc15e67 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -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 { @@ -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 ( diff --git a/src/config/config.ts b/src/config/config.ts index 45ebd5d56b7..c235593e45a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -18,6 +18,7 @@ export { readSourceConfigSnapshot, readSourceConfigSnapshotForWrite, recoverConfigFromLastKnownGood, + recoverConfigFromJsonRootSuffix, resetConfigRuntimeState, resolveConfigSnapshotHash, setRuntimeConfigSnapshotRefreshHandler, diff --git a/src/config/io.ts b/src/config/io.ts index 15f795f8edb..38a68fc8ebd 100644 --- a/src/config/io.ts +++ b/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; + configPath: string; + originalRaw: string; + recoveredRaw: string; +}): Promise { + 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; + configPath: string; + snapshot: ConfigFileSnapshot; +}): Promise { + 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; @@ -1446,6 +1532,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }); } + async function recoverConfigFromJsonRootSuffix(snapshot: ConfigFileSnapshot): Promise { + return await recoverConfigFromJsonRootSuffixWithDeps({ + deps, + configPath, + snapshot, + }); + } + async function readConfigFileSnapshotForWrite(): Promise { 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 { + return await createConfigIO().recoverConfigFromJsonRootSuffix(snapshot); +} + export async function readSourceConfigSnapshot(): Promise { return await readConfigFileSnapshot(); } diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index b019b8442ee..44e26ea57e9 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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"); diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 5e508d67ccd..64ec9de3d92 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -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}`, + ); + }); }); diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 09ab30dca9c..59b363e9399 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -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 }); }