From 3189e2f11ba8ed84c5ce29845fbe978cb96d8dd1 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sun, 8 Feb 2026 10:59:12 -0300 Subject: [PATCH] fix(config): add resolved field to ConfigFileSnapshot for pre-defaults config The initial fix using snapshot.parsed broke configs with $include directives. This commit adds a new 'resolved' field to ConfigFileSnapshot that contains the config after $include and ${ENV} substitution but BEFORE runtime defaults are applied. This is now used by config set/unset to avoid: 1. Breaking configs with $include directives 2. Leaking runtime defaults into the written config file Also removes applyModelDefaults from writeConfigFile since runtime defaults should only be applied when loading, not when writing. --- src/cli/config-cli.ts | 10 ++++++---- src/config/config.ts | 6 +++++- src/config/io.ts | 15 ++++++++++++--- src/config/types.openclaw.ts | 6 ++++++ src/config/validation.ts | 23 +++++++++++++++++++---- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 9b1276fd00..e87ce7c153 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -306,9 +306,10 @@ export function registerConfigCli(program: Command) { } const parsedValue = parseValue(value, opts); const snapshot = await loadValidConfig(); - // Use snapshot.parsed (raw user config) instead of snapshot.config (runtime-merged with defaults) + // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) + // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) - const next = structuredClone(snapshot.parsed) as Record; + const next = structuredClone(snapshot.resolved) as Record; setAtPath(next, parsedPath, parsedValue); await writeConfigFile(next); defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`)); @@ -329,9 +330,10 @@ export function registerConfigCli(program: Command) { throw new Error("Path is empty."); } const snapshot = await loadValidConfig(); - // Use snapshot.parsed (raw user config) instead of snapshot.config (runtime-merged with defaults) + // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) + // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) - const next = structuredClone(snapshot.parsed) as Record; + const next = structuredClone(snapshot.resolved) as Record; const removed = unsetAtPath(next, parsedPath); if (!removed) { defaultRuntime.error(danger(`Config path not found: ${path}`)); diff --git a/src/config/config.ts b/src/config/config.ts index 734a5370cf..baa0179a01 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -10,5 +10,9 @@ export { migrateLegacyConfig } from "./legacy-migrate.js"; export * from "./paths.js"; export * from "./runtime-overrides.js"; export * from "./types.js"; -export { validateConfigObject, validateConfigObjectWithPlugins } from "./validation.js"; +export { + validateConfigObject, + validateConfigObjectRaw, + validateConfigObjectWithPlugins, +} from "./validation.js"; export { OpenClawSchema } from "./zod-schema.js"; diff --git a/src/config/io.ts b/src/config/io.ts index c345e246b9..0164a2231a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -353,6 +353,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: false, raw: null, parsed: {}, + resolved: {}, valid: true, config, hash, @@ -372,6 +373,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw, parsed: {}, + resolved: {}, valid: false, config: {}, hash, @@ -398,6 +400,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw, parsed: parsedRes.parsed, + resolved: coerceConfig(parsedRes.parsed), valid: false, config: coerceConfig(parsedRes.parsed), hash, @@ -426,6 +429,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw, parsed: parsedRes.parsed, + resolved: coerceConfig(resolved), valid: false, config: coerceConfig(resolved), hash, @@ -445,6 +449,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw, parsed: parsedRes.parsed, + resolved: coerceConfig(resolvedConfigRaw), valid: false, config: coerceConfig(resolvedConfigRaw), hash, @@ -460,6 +465,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw, parsed: parsedRes.parsed, + // Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults) + // for config set/unset operations (issue #6070) + resolved: coerceConfig(resolvedConfigRaw), valid: true, config: normalizeConfigPaths( applyTalkApiKey( @@ -481,6 +489,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw: null, parsed: {}, + resolved: {}, valid: false, config: {}, hash: hashConfigRaw(null), @@ -507,9 +516,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } const dir = path.dirname(configPath); await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); - const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2) - .trimEnd() - .concat("\n"); + // 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 tmp = path.join( dir, diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index f2adac8d78..a3ca92c7b9 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -114,6 +114,12 @@ export type ConfigFileSnapshot = { exists: boolean; raw: string | null; parsed: unknown; + /** + * Config after $include resolution and ${ENV} substitution, but BEFORE runtime + * defaults are applied. Use this for config set/unset operations to avoid + * leaking runtime defaults into the written config file. + */ + resolved: OpenClawConfig; valid: boolean; config: OpenClawConfig; hash?: string; diff --git a/src/config/validation.ts b/src/config/validation.ts index 0879ddf2d6..7cb702985e 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -83,7 +83,11 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] return issues; } -export function validateConfigObject( +/** + * Validates config without applying runtime defaults. + * Use this when you need the raw validated config (e.g., for writing back to file). + */ +export function validateConfigObjectRaw( raw: unknown, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const legacyIssues = findLegacyConfigIssues(raw); @@ -124,9 +128,20 @@ export function validateConfigObject( } return { ok: true, - config: applyModelDefaults( - applyAgentDefaults(applySessionDefaults(validated.data as OpenClawConfig)), - ), + config: validated.data as OpenClawConfig, + }; +} + +export function validateConfigObject( + raw: unknown, +): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { + const result = validateConfigObjectRaw(raw); + if (!result.ok) { + return result; + } + return { + ok: true, + config: applyModelDefaults(applyAgentDefaults(applySessionDefaults(result.config))), }; }