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

@@ -13,6 +13,7 @@ export * from "./types.js";
export {
validateConfigObject,
validateConfigObjectRaw,
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
export { OpenClawSchema } from "./zod-schema.js";

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,

View File

@@ -0,0 +1,47 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createConfigIO } from "./io.js";
import { withTempHome } from "./test-helpers.js";
describe("config io write", () => {
it("persists caller changes onto resolved config without leaking runtime defaults", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { port: 18789 } }, null, 2),
"utf-8",
);
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.valid).toBe(true);
const next = structuredClone(snapshot.config);
next.gateway = {
...next.gateway,
auth: { mode: "token" },
};
await io.writeConfigFile(next);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<
string,
unknown
>;
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
expect(persisted).not.toHaveProperty("agents.defaults");
expect(persisted).not.toHaveProperty("messages.ackReaction");
expect(persisted).not.toHaveProperty("sessions.persistence");
});
});
});

View File

@@ -156,7 +156,38 @@ export function validateConfigObjectWithPlugins(raw: unknown):
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
const base = validateConfigObject(raw);
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true });
}
export function validateConfigObjectRawWithPlugins(raw: unknown):
| {
ok: true;
config: OpenClawConfig;
warnings: ConfigValidationIssue[];
}
| {
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false });
}
function validateConfigObjectWithPluginsBase(
raw: unknown,
opts: { applyDefaults: boolean },
):
| {
ok: true;
config: OpenClawConfig;
warnings: ConfigValidationIssue[];
}
| {
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
if (!base.ok) {
return { ok: false, issues: base.issues, warnings: [] };
}