fix(config): enforce default-free persistence in write path

This commit is contained in:
Peter Steinberger
2026-02-13 04:21:34 +01:00
parent 2a9745c9a1
commit 7c25696ab0
4 changed files with 140 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { loadDotEnv } from "../infra/dotenv.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
@@ -28,10 +29,14 @@ import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"
import { collectConfigEnvVars } from "./env-vars.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { applyMergePatch } from "./merge-patch.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
import {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
import { compareOpenClawVersions } from "./version.js";
// Re-export for backwards compatibility
@@ -92,6 +97,49 @@ function coerceConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneUnknown<T>(value: T): T {
return structuredClone(value);
}
function createMergePatch(base: unknown, target: unknown): unknown {
if (!isPlainObject(base) || !isPlainObject(target)) {
return cloneUnknown(target);
}
const patch: Record<string, unknown> = {};
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget) {
patch[key] = null;
continue;
}
const targetValue = target[key];
if (!hasBase) {
patch[key] = cloneUnknown(targetValue);
continue;
}
const baseValue = base[key];
if (isPlainObject(baseValue) && isPlainObject(targetValue)) {
const childPatch = createMergePatch(baseValue, targetValue);
if (isPlainObject(childPatch) && Object.keys(childPatch).length === 0) {
continue;
}
patch[key] = childPatch;
continue;
}
if (!isDeepStrictEqual(baseValue, targetValue)) {
patch[key] = cloneUnknown(targetValue);
}
}
return patch;
}
async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises): Promise<void> {
if (CONFIG_BACKUP_COUNT <= 1) {
return;
@@ -502,7 +550,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(cfg: OpenClawConfig) {
clearConfigCache();
const validated = validateConfigObjectWithPlugins(cfg);
let persistCandidate: unknown = cfg;
const snapshot = await readConfigFileSnapshot();
if (snapshot.valid && snapshot.exists) {
const patch = createMergePatch(snapshot.config, cfg);
persistCandidate = applyMergePatch(snapshot.resolved, patch);
}
const validated = validateConfigObjectRawWithPlugins(persistCandidate);
if (!validated.ok) {
const issue = validated.issues[0];
const pathLabel = issue?.path ? issue.path : "<root>";
@@ -518,7 +573,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
// Do NOT apply runtime defaults when writing — user config should only contain
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
const json = JSON.stringify(stampConfigVersion(cfg), null, 2).trimEnd().concat("\n");
const json = JSON.stringify(stampConfigVersion(validated.config), null, 2)
.trimEnd()
.concat("\n");
const tmp = path.join(
dir,