diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 94ec63b7eec7..85db3c053986 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2714,7 +2714,7 @@ src/config/io.clobber-snapshot.ts 2 src/config/io.context.ts 1 src/config/io.health-state.ts 1 src/config/io.invalid-config.ts 1 -src/config/io.load.ts 3 +src/config/io.load.ts 2 src/config/io.observe-recovery.ts 11 src/config/io.observe.ts 1 src/config/io.read-helpers.ts 3 diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index c1fa2e8a72a1..626f0da06cc4 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -227,6 +227,22 @@ describe("readBestEffortConfig", () => { }); }); + it("materializes fresh-install defaults when the config file is missing", async () => { + await withTempHome(async () => { + const { loadConfig } = await import("./io.runtime.js"); + + const snapshot = await readConfigFileSnapshot({ observe: false }); + const loaded = loadConfig({ pin: false, skipPluginValidation: true }); + + expect(snapshot.exists).toBe(false); + // Missing config = fresh install; snapshot and load must produce the same + // out-of-box defaults an existing empty {} config gets (contextPruning + // stays provider-conditional, so compaction is the parity signal here). + expect(snapshot.config.agents?.defaults?.compaction?.mode).toBe("safeguard"); + expect(loaded.agents?.defaults?.compaction?.mode).toBe("safeguard"); + }); + }); + it("reuses valid snapshots while preserving load-time defaults", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { diff --git a/src/config/io.load.ts b/src/config/io.load.ts index 7d406ad4f4b6..4252db4013c3 100644 --- a/src/config/io.load.ts +++ b/src/config/io.load.ts @@ -55,7 +55,15 @@ export function loadConfigFromContext( timeoutMs: resolveShellEnvFallbackTimeoutMs(deps.env), }); } - return migratePersistedImplicitMainRoster({}).config as OpenClawConfig; + // A missing config is the fresh-install default path: materialize the + // same runtime defaults an empty {} config gets, or out-of-box behavior + // (compaction safeguard, session/cron defaults) silently diverges. + return materializeConfigForLoad( + context, + coerceConfig(migratePersistedImplicitMainRoster({}).config), + {}, + undefined, + ); } const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = deps.json5.parse(raw); diff --git a/src/config/io.snapshot.ts b/src/config/io.snapshot.ts index 9d1b675d30d6..f815aef433db 100644 --- a/src/config/io.snapshot.ts +++ b/src/config/io.snapshot.ts @@ -71,7 +71,13 @@ export async function readConfigFileSnapshotInternal( parsed: {}, sourceConfig: config, valid: true, - runtimeConfig: config, + // Missing config is the fresh-install default path: materialize the + // same runtime defaults an existing empty {} config gets, so snapshot + // consumers see identical out-of-box behavior either way. + runtimeConfig: materializeRuntimeConfig(config, "snapshot", { + manifestRegistry: + context.options.pluginValidation === "core-only" ? { plugins: [] } : undefined, + }), hash: hashConfigRaw(null), issues: [], warnings: [], diff --git a/src/config/materialize.ts b/src/config/materialize.ts index c05e533d177b..22be388e05ff 100644 --- a/src/config/materialize.ts +++ b/src/config/materialize.ts @@ -16,36 +16,12 @@ import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin. import { normalizeConfigPaths } from "./normalize-paths.js"; import type { OpenClawConfig, ResolvedSourceConfig, RuntimeConfig } from "./types.js"; -type ConfigMaterializationMode = "load" | "missing" | "snapshot"; - -/** Defaults profile selected for config load, missing-file, or snapshot materialization. */ -type MaterializationProfile = { - includeCompactionDefaults: boolean; - includeContextPruningDefaults: boolean; - includeLoggingDefaults: boolean; - normalizePaths: boolean; -}; - // Snapshot and load must materialize identically: prepared-runtime exact-config // resolution compares the startup-published (snapshot) config against the reply-path // (load) config, and any divergence permanently fails that resolve for affected configs. -const FULL_MATERIALIZATION_PROFILE: MaterializationProfile = { - includeCompactionDefaults: true, - includeContextPruningDefaults: true, - includeLoggingDefaults: true, - normalizePaths: true, -}; - -const MATERIALIZATION_PROFILES: Record = { - load: FULL_MATERIALIZATION_PROFILE, - missing: { - includeCompactionDefaults: true, - includeContextPruningDefaults: true, - includeLoggingDefaults: false, - normalizePaths: false, - }, - snapshot: FULL_MATERIALIZATION_PROFILE, -}; +// The mode parameter documents the call site; a per-mode defaults profile existed +// until its last divergent ("missing") caller was removed and only invited drift. +type ConfigMaterializationMode = "load" | "snapshot"; export function asResolvedSourceConfig(config: OpenClawConfig): ResolvedSourceConfig { return config as ResolvedSourceConfig; @@ -57,34 +33,25 @@ export function asRuntimeConfig(config: OpenClawConfig): RuntimeConfig { export function materializeRuntimeConfig( config: OpenClawConfig, - mode: ConfigMaterializationMode, + _mode: ConfigMaterializationMode, options: { manifestRegistry?: Pick; loadManifestRegistry?: () => Pick | undefined; } = {}, ): RuntimeConfig { - const profile = MATERIALIZATION_PROFILES[mode]; let next = applyMessageDefaults(config); - if (profile.includeLoggingDefaults) { - next = applyLoggingDefaults(next); - } + next = applyLoggingDefaults(next); next = applySessionDefaults(next); next = applyAgentDefaults(next); next = applyCronDefaults(next); - if (profile.includeContextPruningDefaults) { - next = applyContextPruningDefaults(next, { manifestRegistry: options.manifestRegistry }); - } - if (profile.includeCompactionDefaults) { - next = applyCompactionDefaults(next); - } + next = applyContextPruningDefaults(next, { manifestRegistry: options.manifestRegistry }); + next = applyCompactionDefaults(next); next = applyModelDefaults(next, { manifestRegistry: options.manifestRegistry, loadManifestRegistry: options.loadManifestRegistry, }); next = applyTalkConfigNormalization(next); - if (profile.normalizePaths) { - normalizeConfigPaths(next); - } + normalizeConfigPaths(next); normalizeExecSafeBinProfilesInConfig(next); return asRuntimeConfig(inheritLegacyDefaultAgentId(config, next)); }