From c49ed32f4567fe004a9c04d95348ebf8d46da557 Mon Sep 17 00:00:00 2001 From: sallyom Date: Fri, 1 May 2026 01:31:56 -0400 Subject: [PATCH] fix(config): log observe recovery write failures --- CHANGELOG.md | 1 + src/config/io.observe-recovery.test.ts | 83 ++++++++++++++++++++++++++ src/config/io.observe-recovery.ts | 20 +++++-- src/config/io.ts | 12 ++-- src/config/io.write-config.test.ts | 60 +++++++++++++++++++ 5 files changed, 166 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed468bd1c6..2105071894b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc. - Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx. - Discord/doctor: migrate unsupported per-channel `agentId` entries under guild channel config into top-level `bindings[]` routes, so `openclaw doctor --fix` preserves the intended agent route instead of stripping it as an unknown key. Fixes #62455. Thanks @lobster-biscuit. +- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom. ## 2026.4.30 diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index 4352788e387..89de8f02c1d 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -159,6 +159,47 @@ describe("config observe recovery", () => { }; } + function withAsyncHealthWriteFailure( + deps: ObserveRecoveryDeps, + healthPath: string, + ): ObserveRecoveryDeps { + const writeFile = deps.fs.promises.writeFile.bind(deps.fs.promises); + return { + ...deps, + fs: { + ...deps.fs, + promises: { + ...deps.fs.promises, + writeFile: async (target, data, options) => { + if (target === healthPath) { + throw new Error("health write failed"); + } + return await writeFile(target, data, options); + }, + }, + }, + }; + } + + function withSyncHealthWriteFailure( + deps: ObserveRecoveryDeps, + healthPath: string, + ): ObserveRecoveryDeps { + const writeFileSync = deps.fs.writeFileSync.bind(deps.fs); + return { + ...deps, + fs: { + ...deps.fs, + writeFileSync: (target, data, options) => { + if (target === healthPath) { + throw new Error("health write failed"); + } + return writeFileSync(target, data, options); + }, + }, + }; + } + it("auto-restores suspicious update-channel-only roots from backup", async () => { await withSuiteHome(async (home) => { const { deps, configPath, auditPath, warn } = makeDeps(home); @@ -383,6 +424,48 @@ describe("config observe recovery", () => { }); }); + it("logs async health-state write failures", async () => { + await withSuiteHome(async (home) => { + const { deps, configPath, warn } = makeDeps(home); + const snapshot = await makeSnapshot(configPath, recoverableTelegramConfig); + const healthPath = path.join(home, ".openclaw", "logs", "config-health.json"); + + await expect( + promoteConfigSnapshotToLastKnownGood({ + deps: withAsyncHealthWriteFailure(deps, healthPath), + snapshot, + logger: deps.logger, + }), + ).resolves.toBe(true); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + `Config health-state write failed: ${healthPath}: health write failed`, + ), + ); + }); + }); + + it("logs sync health-state write failures", async () => { + await withSuiteHome(async (home) => { + const { deps, configPath, warn } = makeDeps(home); + const healthPath = path.join(home, ".openclaw", "logs", "config-health.json"); + await seedConfigBackup(configPath, recoverableTelegramConfig); + await writeClobberedUpdateChannel(configPath); + + recoverClobberedUpdateChannelSync({ + deps: withSyncHealthWriteFailure(deps, healthPath), + configPath, + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + `Config health-state write failed: ${healthPath}: health write failed`, + ), + ); + }); + }); + it("promotes a valid startup config and restores it after an invalid direct edit", async () => { await withSuiteHome(async (home) => { const { deps, configPath, auditPath, warn } = makeDeps(home); diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index ee865c67a52..028b1f1025e 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -313,6 +313,10 @@ function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => str return path.join(resolveStateDir(env, homedir), "logs", "config-health.json"); } +function formatObserveRecoveryError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + async function readConfigHealthState(deps: ObserveRecoveryDeps): Promise { try { const raw = await deps.fs.promises.readFile( @@ -340,25 +344,33 @@ async function writeConfigHealthState( deps: ObserveRecoveryDeps, state: ConfigHealthState, ): Promise { + const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); try { - const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 }); await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, }); - } catch {} + } catch (err) { + deps.logger.warn( + `Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`, + ); + } } function writeConfigHealthStateSync(deps: ObserveRecoveryDeps, state: ConfigHealthState): void { + const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); try { - const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 }); deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, }); - } catch {} + } catch (err) { + deps.logger.warn( + `Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`, + ); + } } function getConfigHealthEntry(state: ConfigHealthState, configPath: string): ConfigHealthEntry { diff --git a/src/config/io.ts b/src/config/io.ts index eea54238b21..e0513899626 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -441,28 +441,28 @@ async function writeConfigHealthState( deps: Required, state: ConfigHealthState, ): Promise { + const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); try { - const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 }); await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, }); - } catch { - // best-effort + } catch (err) { + deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`); } } function writeConfigHealthStateSync(deps: Required, state: ConfigHealthState): void { + const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); try { - const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir); deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 }); deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, }); - } catch { - // best-effort + } catch (err) { + deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`); } } diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index a3b40fbba32..634914ae79e 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1,3 +1,4 @@ +import fsNode from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -103,6 +104,65 @@ describe("config io write", () => { logger: silentLogger, }); + function withHealthStateWriteFailure(healthPath: string): typeof fsNode { + const writeFile = fsNode.promises.writeFile.bind(fsNode.promises); + const writeFileSync = fsNode.writeFileSync.bind(fsNode); + return { + ...fsNode, + promises: { + ...fsNode.promises, + writeFile: async (target, data, options) => { + if (target === healthPath) { + throw new Error("health write failed"); + } + return await writeFile(target, data, options); + }, + }, + writeFileSync: (target, data, options) => { + if (target === healthPath) { + throw new Error("health write failed"); + } + return writeFileSync(target, data, options); + }, + }; + } + + it("logs health-state write failures through public config reads", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const healthPath = path.join(home, ".openclaw", "logs", "config-health.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`, + "utf-8", + ); + const warn = vi.fn(); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + fs: withHealthStateWriteFailure(healthPath), + homedir: () => home, + logger: { warn, error: vi.fn() }, + }); + + await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true }); + expect(() => io.loadConfig()).not.toThrow(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + `Config health-state write failed: ${healthPath}: health write failed`, + ), + ); + expect( + warn.mock.calls.filter( + ([message]) => + typeof message === "string" && message.includes("Config health-state write failed:"), + ), + ).toHaveLength(2); + }); + }); + it("migrates shipped plugin install config records into the plugin index", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json");