From 7393a1f64dcbd500fbb0d968613ca66f19d5b765 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 22:12:52 -0700 Subject: [PATCH] fix(config): record why best-effort reads fell back (#125010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readSourceConfigBestEffortFromContext collapsed three distinct failures — parse error, $include resolution failure, any other read error — into {} or a root-only config with zero recorded fact. A corrupt config (or an update.channel living in an unresolvable $include) silently flipped the update-channel decision and doctor-lint input to defaults, indistinguishable from a missing file. The main snapshot path logs an actionable message for the same conditions. Root cause: 'best effort' legitimized the fallback value AND the silence. Each degradation branch now warns once with the config path and cause; the fallback values are unchanged. Regression: unparseable config yields {} plus a recorded 'best-effort read ignored unparseable config' warning — fails pre-fix (silent). --- src/config/io.best-effort.test.ts | 23 +++++++++++++++++++++++ src/config/io.snapshot.ts | 15 +++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index 53a1270a3a68..c1fa2e8a72a1 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -154,6 +154,29 @@ describe("readBestEffortConfig", () => { }); }); + it("records why an unparseable config was ignored by best-effort reads", async () => { + await withTempHome(async (home) => { + const configPath = `${home}/.openclaw/openclaw.json`; + await fs.mkdir(`${home}/.openclaw`, { recursive: true }); + await fs.writeFile(configPath, "{ definitely not json", "utf-8"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const config = await readSourceConfigBestEffort(); + + // The fallback value stays {} — but the degradation is recorded. + expect(config).toEqual({}); + expect( + warn.mock.calls.some(([line]) => + String(line).includes("best-effort read ignored unparseable config"), + ), + ).toBe(true); + } finally { + warn.mockRestore(); + } + }); + }); + it("preserves Windows case-insensitive env lookup in isolated reads", async () => { await withTempHome(async (home) => { const mixedCaseKey = "OpenClaw_Config_Path"; diff --git a/src/config/io.snapshot.ts b/src/config/io.snapshot.ts index 59adb0fdd469..9d1b675d30d6 100644 --- a/src/config/io.snapshot.ts +++ b/src/config/io.snapshot.ts @@ -1,3 +1,4 @@ +import { formatErrorMessage } from "../infra/errors.js"; import { includeContributionOwnsAgentRoster, includeContributionOwnsBindings, @@ -454,21 +455,31 @@ export async function readSourceConfigBestEffortFromContext( if (!deps.fs.existsSync(configPath)) { return {}; } + // Best-effort legitimizes the fallback value, not the silence: consumers + // (update-channel selection, doctor lint) act on the result, so each + // degradation records why the real config was not used. try { const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = parseConfigJson5(raw, deps.json5); if (!parsed.ok) { + deps.logger.warn( + `Config (${configPath}): best-effort read ignored unparseable config: ${parsed.error}`, + ); return {}; } let resolved: unknown; try { resolved = resolveConfigIncludesForRead(parsed.parsed, configPath, deps); - } catch { + } catch (err) { + deps.logger.warn( + `Config (${configPath}): best-effort read skipped $include resolution: ${formatErrorMessage(err)}`, + ); return coerceConfig(parsed.parsed); } const resolution = resolveConfigForRead(resolved, deps.env, deps.lowerPrecedenceEnv); return coerceConfig(resolution.resolvedConfigRaw); - } catch { + } catch (err) { + deps.logger.warn(`Config (${configPath}): best-effort read failed: ${formatErrorMessage(err)}`); return {}; } }