diff --git a/src/config/config.ts b/src/config/config.ts index baa0179a01..4761b7b215 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -13,6 +13,7 @@ export * from "./types.js"; export { validateConfigObject, validateConfigObjectRaw, + validateConfigObjectRawWithPlugins, validateConfigObjectWithPlugins, } from "./validation.js"; export { OpenClawSchema } from "./zod-schema.js"; diff --git a/src/config/io.ts b/src/config/io.ts index 0164a2231a..19b1f02e73 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cloneUnknown(value: T): T { + return structuredClone(value); +} + +function createMergePatch(base: unknown, target: unknown): unknown { + if (!isPlainObject(base) || !isPlainObject(target)) { + return cloneUnknown(target); + } + + const patch: Record = {}; + 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 { 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 : ""; @@ -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, diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts new file mode 100644 index 0000000000..cff5cd245e --- /dev/null +++ b/src/config/io.write-config.test.ts @@ -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"); + }); + }); +}); diff --git a/src/config/validation.ts b/src/config/validation.ts index 7cb702985e..9f01ad1b24 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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: [] }; }