fix(config): materialize fresh-install defaults for missing config (#125110)

* fix(config): materialize fresh-install defaults for missing config

A missing config file (fresh install) skipped runtime-defaults
materialization on both loadConfig and readConfigFileSnapshot, while an
existing empty {} config got the full defaults (compaction safeguard,
session/cron/model defaults). Out-of-box behavior silently diverged from
the documented defaults until the first config write created the file.
The drift dates to bc75968074, which dropped the missing-branch
materializeRuntimeConfig call during an import-trim.

Route the missing-file branches through the same load/snapshot
materialization as existing configs, and delete the now-unreferenced
per-mode defaults profile table in materialize.ts — load and snapshot
must materialize identically anyway (prepared-runtime exact-config
resolution depends on it), so the profile indirection only invited
drift.

* chore(ratchet): shrink io.load.ts assertion baseline to 2

The missing-config fix removed an 'as OpenClawConfig' cast; the
assertion-safety ratchet requires the baseline to shrink with it.
This commit is contained in:
Peter Steinberger
2026-08-16 23:48:46 -07:00
committed by GitHub
parent b1e76c4640
commit 7b18510e6c
5 changed files with 41 additions and 44 deletions
+1 -1
View File
@@ -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
+16
View File
@@ -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, {
+9 -1
View File
@@ -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);
+7 -1
View File
@@ -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: [],
+8 -41
View File
@@ -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<ConfigMaterializationMode, MaterializationProfile> = {
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<PluginManifestRegistry, "plugins">;
loadManifestRegistry?: () => Pick<PluginManifestRegistry, "plugins"> | 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));
}