fix(config): record why best-effort reads fell back (#125010)

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).
This commit is contained in:
Peter Steinberger
2026-08-16 22:12:52 -07:00
committed by GitHub
parent 4b2aa935f3
commit 7393a1f64d
2 changed files with 36 additions and 2 deletions
+23
View File
@@ -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";
+13 -2
View File
@@ -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 {};
}
}