From 7b2c9a6fa3d3d1c79e6e6f0574efffc71cdfc9d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 23:35:06 +0100 Subject: [PATCH] fix(config): recover critical config clobbers --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 3 + docs/gateway/troubleshooting.md | 1 + src/config/io.observe-recovery.test.ts | 113 +++++++++++++++++++++++++ src/config/io.observe-recovery.ts | 11 ++- 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6e3e831d0..b8d7bc27fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653. - Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo. +- Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing `gateway.mode`, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336. - Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only. - Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci. - Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e68a2b51e34..7b6409652e9 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -100,6 +100,9 @@ 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. +Startup read recovery also treats sharp size drops, missing config metadata, and a +missing `gateway.mode` as critical clobber signatures when the last-known-good +copy had those fields. 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 diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index de864780ab2..820f769afbd 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -303,6 +303,7 @@ Common signatures: - `.clobbered.*` exists → an external direct edit or startup read was restored. - `.rejected.*` exists → an OpenClaw-owned config write failed schema or clobber checks before commit. - `Config write rejected:` → the write tried to drop required shape, shrink the file sharply, or persist invalid config. +- `missing-meta-vs-last-good`, `gateway-mode-missing-vs-last-good`, or `size-drop-vs-last-good:*` → startup treated the current file as clobbered because it lost fields or size compared with the last-known-good backup. - `Config last-known-good promotion skipped` → the candidate contained redacted secret placeholders such as `***`. Fix options: diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index 5b0dba8ce26..b5f85c09bcb 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -20,6 +20,7 @@ describe("config observe recovery", () => { const clobberedUpdateChannelConfig = { update: { channel: "beta" } }; const clobberedUpdateChannelRaw = `${JSON.stringify(clobberedUpdateChannelConfig, null, 2)}\n`; const recoverableTelegramConfig = { + meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, update: { channel: "beta" }, gateway: { mode: "local" }, channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } }, @@ -49,6 +50,12 @@ describe("config observe recovery", () => { await fsp.copyFile(configPath, `${configPath}.bak`); } + async function writeConfigRaw(configPath: string, config: Record) { + const raw = `${JSON.stringify(config, null, 2)}\n`; + await fsp.writeFile(configPath, raw, "utf-8"); + return { raw, parsed: config }; + } + async function writeClobberedUpdateChannel(configPath: string) { await fsp.writeFile(configPath, clobberedUpdateChannelRaw, "utf-8"); return { @@ -82,6 +89,20 @@ describe("config observe recovery", () => { }); } + async function recoverSuspiciousConfigRead(params: { + deps: ObserveRecoveryDeps; + configPath: string; + raw: string; + parsed: unknown; + }) { + return await maybeRecoverSuspiciousConfigRead({ + deps: params.deps, + configPath: params.configPath, + raw: params.raw, + parsed: params.parsed, + }); + } + function recoverClobberedUpdateChannelSync(params: { deps: ObserveRecoveryDeps; configPath: string; @@ -142,6 +163,7 @@ describe("config observe recovery", () => { await withSuiteHome(async (home) => { const { deps, configPath, auditPath, warn } = makeDeps(home); await seedConfigBackup(configPath, { + meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, update: { channel: "beta" }, browser: { enabled: true }, gateway: { mode: "local", auth: { mode: "token", token: "secret-token" } }, @@ -165,6 +187,97 @@ describe("config observe recovery", () => { }); }); + it("auto-restores when metadata disappears from an otherwise valid config", async () => { + await withSuiteHome(async (home) => { + const { deps, configPath, auditPath } = makeDeps(home); + await seedConfigBackup(configPath, recoverableTelegramConfig); + const clobbered = await writeConfigRaw(configPath, { + update: { channel: "beta" }, + gateway: { mode: "local" }, + channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } }, + }); + + const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...clobbered }); + + expect((recovered.parsed as { meta?: unknown }).meta).toEqual(recoverableTelegramConfig.meta); + const observe = await readLastObserveEvent(auditPath); + expect(observe?.restoredFromBackup).toBe(true); + expect(observe?.suspicious).toEqual(expect.arrayContaining(["missing-meta-vs-last-good"])); + }); + }); + + it("auto-restores when gateway mode disappears from the last-good shape", async () => { + await withSuiteHome(async (home) => { + const { deps, configPath, auditPath } = makeDeps(home); + await seedConfigBackup(configPath, recoverableTelegramConfig); + const clobbered = await writeConfigRaw(configPath, { + meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, + update: { channel: "beta" }, + channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } }, + }); + + const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...clobbered }); + + expect((recovered.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local"); + const observe = await readLastObserveEvent(auditPath); + expect(observe?.restoredFromBackup).toBe(true); + expect(observe?.suspicious).toEqual( + expect.arrayContaining(["gateway-mode-missing-vs-last-good"]), + ); + }); + }); + + it("auto-restores after a large size drop against last-good config", async () => { + await withSuiteHome(async (home) => { + const { deps, configPath, auditPath } = makeDeps(home); + await seedConfigBackup(configPath, { + ...recoverableTelegramConfig, + channels: { + telegram: { + enabled: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: Array.from({ length: 60 }, (_, index) => `telegram-user-${index}`), + }, + }, + }); + const clobbered = await writeConfigRaw(configPath, { + meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, + gateway: { mode: "local" }, + }); + + const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...clobbered }); + + expect( + (recovered.parsed as { channels?: { telegram?: { allowFrom?: string[] } } }).channels + ?.telegram?.allowFrom, + ).toHaveLength(60); + const observe = await readLastObserveEvent(auditPath); + expect(observe?.restoredFromBackup).toBe(true); + expect(observe?.suspicious).toEqual( + expect.arrayContaining([expect.stringMatching(/^size-drop-vs-last-good:/)]), + ); + }); + }); + + it("does not restore noncritical config edits", async () => { + await withSuiteHome(async (home) => { + const { deps, configPath, auditPath } = makeDeps(home); + await seedConfigBackup(configPath, recoverableTelegramConfig); + const editedConfig = { + ...recoverableTelegramConfig, + update: { channel: "stable" }, + }; + const edited = await writeConfigRaw(configPath, editedConfig); + + const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...edited }); + + expect(recovered.parsed).toEqual(editedConfig); + await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(edited.raw); + await expect(fsp.stat(auditPath)).rejects.toThrow(); + }); + }); + it("dedupes repeated suspicious hashes", async () => { await withSuiteHome(async (home) => { const { deps, configPath, auditPath } = makeDeps(home); diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index 5f13a47bd51..952ee936556 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -441,6 +441,15 @@ function resolveSuspiciousSignature( return `${current.hash}:${suspicious.join(",")}`; } +function isRecoverableConfigReadSuspiciousReason(reason: string): boolean { + return ( + reason === "missing-meta-vs-last-good" || + reason === "gateway-mode-missing-vs-last-good" || + reason === "update-channel-only-root" || + reason.startsWith("size-drop-vs-last-good:") + ); +} + function resolveConfigReadRecoveryContext(params: { current: ConfigHealthFingerprint; parsed: unknown; @@ -454,7 +463,7 @@ function resolveConfigReadRecoveryContext(params: { parsed: params.parsed, lastKnownGood: params.backupBaseline, }); - if (!suspicious.includes("update-channel-only-root")) { + if (!suspicious.some(isRecoverableConfigReadSuspiciousReason)) { return null; } const suspiciousSignature = resolveSuspiciousSignature(params.current, suspicious);