// Config snapshots and pre/post-update config restoration. import fs from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import type { LegacyConfigUpdatePlan } from "../../commands/doctor/legacy-config-repair.js"; import { createConfigIO, mutateConfigFileWithRetry, parseConfigJson5, readConfigFileSnapshot, } from "../../config/config.js"; import { resolveConfigEnvVars } from "../../config/env-substitution.js"; import { resolveConfigIncludes } from "../../config/includes.js"; import type { ConfigWriteOptions } from "../../config/io.types.js"; import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js"; import { resolveIncludeRoots } from "../../config/paths.js"; import { parsePluginInstallRecordMap } from "../../config/plugin-install-record-map.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { shouldWarnOnTouchedVersion } from "../../config/version.js"; import { normalizeUpdateChannel, type UpdateChannel } from "../../infra/update-channels.js"; import type { PreUpdateConfigRestoreInput } from "../../infra/update-post-core-context.js"; import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js"; import { defaultRuntime } from "../../runtime.js"; import { VERSION } from "../../version.js"; const PRE_UPDATE_CONFIG_SNAPSHOT_MAX_AGE_MS = 6 * 60 * 60 * 1000; export function normalizePluginInstallRecordMap( value: unknown, ): Record { const records = parsePluginInstallRecordMap(value); if (!records) { throw new Error("Invalid plugin install record map"); } return records; } function normalizeChannelConfigMap(value: unknown): Record | null { if (!isRecord(value)) { return null; } return value; } function normalizeDirectAuthoredChannelConfigMap(value: unknown): Record | null { const channels = normalizeChannelConfigMap(value); if (!channels || Object.hasOwn(channels, "$include")) { return null; } return channels; } function restorePreUpdateChannelModelOverrides(params: { channels: Record; preUpdateChannels: Record; restoredChannelIds: string[]; }): { channels: Record; changed: boolean } { if (params.restoredChannelIds.length === 0) { return { channels: params.channels, changed: false }; } const preUpdateModelByChannel = normalizeChannelConfigMap( params.preUpdateChannels.modelByChannel, ); if (!preUpdateModelByChannel) { return { channels: params.channels, changed: false }; } const currentModelByChannel = normalizeChannelConfigMap(params.channels.modelByChannel) ?? {}; const restoredModelByChannel = structuredClone(currentModelByChannel); let changed = false; for (const [providerId, providerOverrides] of Object.entries(preUpdateModelByChannel)) { const preUpdateProviderOverrides = normalizeChannelConfigMap(providerOverrides); if (!preUpdateProviderOverrides) { continue; } const currentProviderOverrides = normalizeChannelConfigMap(restoredModelByChannel[providerId]) ?? {}; let providerChanged = false; for (const channelId of params.restoredChannelIds) { if ( currentProviderOverrides[channelId] !== undefined || preUpdateProviderOverrides[channelId] === undefined ) { continue; } currentProviderOverrides[channelId] = structuredClone(preUpdateProviderOverrides[channelId]); providerChanged = true; } if (providerChanged) { restoredModelByChannel[providerId] = currentProviderOverrides; changed = true; } } return changed ? { channels: { ...params.channels, modelByChannel: restoredModelByChannel }, changed: true } : { channels: params.channels, changed: false }; } function restoreDroppedPreUpdateChannels( snapshot: Awaited>, preUpdateConfig: PreUpdateConfigRestoreInput | undefined, ): { snapshot: Awaited>; changed: boolean; authoredChannels?: unknown; } { if (!snapshot.valid || !preUpdateConfig) { return { snapshot, changed: false }; } const preUpdateChannels = normalizeChannelConfigMap(preUpdateConfig.sourceConfig.channels); if (!preUpdateChannels) { return { snapshot, changed: false }; } const postUpdateChannels = normalizeChannelConfigMap(snapshot.sourceConfig.channels) ?? {}; let restoredChannels = { ...postUpdateChannels }; const restoredChannelIds: string[] = []; let restored = false; for (const [channelId, channelConfig] of Object.entries(preUpdateChannels)) { if (restoredChannels[channelId] !== undefined) { continue; } restoredChannels[channelId] = structuredClone(channelConfig); if (channelId !== "modelByChannel") { restoredChannelIds.push(channelId); } restored = true; } if (!restored) { return { snapshot, changed: false }; } const restoredModelOverrides = restorePreUpdateChannelModelOverrides({ channels: restoredChannels, preUpdateChannels, restoredChannelIds, }); restoredChannels = restoredModelOverrides.channels; const authoredChannels = resolveRestoredAuthoredChannels({ currentChannels: snapshot.sourceConfig.channels, currentAuthoredChannels: isRecord(snapshot.parsed) ? (snapshot.parsed as OpenClawConfig).channels : snapshot.sourceConfig.channels, preUpdateAuthoredChannels: preUpdateConfig.authoredConfig.channels, restoredChannelIds, }); const nextConfig = { ...snapshot.sourceConfig, channels: restoredChannels, } as OpenClawConfig; return { snapshot: { ...createUpdatedConfigSnapshot(snapshot, nextConfig), hash: snapshot.hash, }, changed: true, ...(authoredChannels !== undefined ? { authoredChannels } : {}), }; } function hasRestorablePreUpdateChannels( snapshot: Awaited>, preUpdateConfig: PreUpdateConfigRestoreInput, ): boolean { if (!snapshot.valid) { return false; } const preUpdateChannels = normalizeChannelConfigMap(preUpdateConfig.sourceConfig.channels); if (!preUpdateChannels) { return false; } const postUpdateChannels = normalizeChannelConfigMap(snapshot.sourceConfig.channels) ?? {}; return Object.keys(preUpdateChannels).some( (channelId) => postUpdateChannels[channelId] === undefined, ); } function resolveRestoredAuthoredChannels(params: { currentChannels: unknown; currentAuthoredChannels: unknown; preUpdateAuthoredChannels: unknown; restoredChannelIds: string[]; }): unknown { if (params.preUpdateAuthoredChannels === undefined) { return undefined; } const directAuthoredChannels = normalizeDirectAuthoredChannelConfigMap( params.preUpdateAuthoredChannels, ); if (!directAuthoredChannels) { const preUpdateAuthoredChannels = normalizeChannelConfigMap(params.preUpdateAuthoredChannels); if (!preUpdateAuthoredChannels) { return undefined; } const currentDirectAuthoredChannels = normalizeDirectAuthoredChannelConfigMap( params.currentAuthoredChannels, ); if (currentDirectAuthoredChannels) { return { ...structuredClone(preUpdateAuthoredChannels), ...structuredClone(currentDirectAuthoredChannels), }; } const currentAuthoredChannels = normalizeChannelConfigMap(params.currentAuthoredChannels); return !currentAuthoredChannels || Object.keys(currentAuthoredChannels).length === 0 ? structuredClone(preUpdateAuthoredChannels) : undefined; } const currentChannels = normalizeDirectAuthoredChannelConfigMap(params.currentAuthoredChannels) ?? normalizeDirectAuthoredChannelConfigMap(params.currentChannels) ?? {}; const restoredChannels = { ...currentChannels }; let changed = false; for (const channelId of params.restoredChannelIds) { if ( restoredChannels[channelId] !== undefined || directAuthoredChannels[channelId] === undefined ) { continue; } restoredChannels[channelId] = structuredClone(directAuthoredChannels[channelId]); changed = true; } const restoredModelOverrides = restorePreUpdateChannelModelOverrides({ channels: restoredChannels, preUpdateChannels: directAuthoredChannels, restoredChannelIds: params.restoredChannelIds, }); if (restoredModelOverrides.changed) { return restoredModelOverrides.channels; } return changed ? restoredChannels : undefined; } export async function persistValidatedDowngradeConfig( snapshot: Awaited>, ): Promise { if ( snapshot.valid && shouldWarnOnTouchedVersion(VERSION, snapshot.sourceConfig.meta?.lastTouchedVersion) ) { // Strict target validation permits this write even when Doctor execution failed. // Committing unchanged config through its normal writer stamps the target version, // so same-channel downgrades retain ordinary restart eligibility. await withPluginLifecycleLease({}, async () => { await mutateConfigFileWithRetry({ mutate: () => undefined }); }); } } export async function persistRequestedUpdateChannel(params: { configSnapshot: Awaited>; requestedChannel: UpdateChannel | null; }): Promise>> { if (!params.requestedChannel || !params.configSnapshot.valid) { return params.configSnapshot; } const storedChannel = normalizeUpdateChannel(params.configSnapshot.config.update?.channel); if (params.requestedChannel === storedChannel) { return params.configSnapshot; } const requestedChannel = params.requestedChannel; const mutation = await mutateConfigFileWithRetry({ writeOptions: { skipPluginValidation: true }, mutate: (draft) => { draft.update = { ...draft.update, channel: requestedChannel, }; }, }); return createUpdatedConfigSnapshot(mutation.snapshot, mutation.nextConfig); } /** Capture write provenance in the process that will converge plugins, after any channel write. */ export async function preparePostCorePluginConfig(params: { requestedChannel: UpdateChannel | null; preUpdateConfig?: PreUpdateConfigRestoreInput; suppressFutureVersionWarning?: boolean; observe?: boolean; }) { const io = createConfigIO({ pluginValidation: "skip", suppressFutureVersionWarning: params.suppressFutureVersionWarning, observe: params.observe, }); let prepared = await io.readConfigFileSnapshotForWrite(); const channelSnapshot = await persistRequestedUpdateChannel({ configSnapshot: prepared.snapshot, requestedChannel: params.requestedChannel, }); if (channelSnapshot !== prepared.snapshot) { prepared = await io.readConfigFileSnapshotForWrite(); } const restored = restoreDroppedPreUpdateChannels(prepared.snapshot, params.preUpdateConfig); return { configSnapshot: restored.snapshot, configWriteOptions: prepared.writeOptions, configChanged: restored.changed, restoredAuthoredChannels: restored.authoredChannels, }; } function createUpdatedConfigSnapshot( snapshot: Awaited>, next: OpenClawConfig, ): Awaited> { if (!snapshot.valid) { return snapshot; } return { ...snapshot, hash: undefined, parsed: next, sourceConfig: asResolvedSourceConfig(next), resolved: asResolvedSourceConfig(next), runtimeConfig: asRuntimeConfig(next), config: asRuntimeConfig(next), }; } /** Read-only startup configuration, retaining the authored snapshot alongside any projection. */ export async function readUpdateChannelConfig(channelRequested: boolean) { const configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true, observe: false, }); const legacyConfigPlan = channelRequested ? await planUpdateChannelLegacyConfig(configSnapshot) : undefined; const plannedConfig = legacyConfigPlan?.config ?? (configSnapshot.valid ? configSnapshot.config : undefined); return { configSnapshot, legacyConfigPlan, storedChannel: normalizeUpdateChannel(plannedConfig?.update?.channel), }; } /** Preserve authored bytes during target admission; the projection grants no write authority. */ async function planUpdateChannelLegacyConfig( snapshot: Awaited>, ): Promise { if (snapshot.valid || snapshot.legacyIssues.length === 0) { return undefined; } const { planLegacyConfigForUpdateChannel } = await import("../../commands/doctor/legacy-config-repair.js"); const plan = planLegacyConfigForUpdateChannel(snapshot); if (!plan || !snapshot.includedPaths?.length) { return plan; } const current = await createConfigIO({ observe: false, pluginValidation: "skip", }).readConfigFileSnapshotForWrite(); const keys = [ "path", "exists", "raw", "hash", "includedPaths", "includeProvenance", "sourceConfig", ] as const; if (keys.some((key) => !isDeepStrictEqual(snapshot[key], current.snapshot[key]))) { throw new Error( "Legacy configuration changed during update planning; retry against the current source.", ); } return planLegacyConfigForUpdateChannel(snapshot, current.writeOptions); } export async function maybeRepairLegacyConfigForUpdateChannel(params: { plan?: LegacyConfigUpdatePlan; configSnapshot: Awaited>; configWriteOptions?: ConfigWriteOptions; jsonMode: boolean; }): Promise>> { if ( !params.plan && (params.configSnapshot.valid || params.configSnapshot.legacyIssues.length === 0) ) { return params.configSnapshot; } const { repairLegacyConfigForUpdateChannel } = await import("../../commands/doctor/legacy-config-repair.js"); const { snapshot, repaired } = await repairLegacyConfigForUpdateChannel(params); if (!params.jsonMode && repaired) { defaultRuntime.log(theme.muted("Migrated legacy config before changing update channel.")); } return snapshot; } export async function writePostCoreSourceConfigFile( filePath: string, preUpdateConfig: PreUpdateConfigRestoreInput | undefined, ): Promise { if (!preUpdateConfig) { return; } await fs.writeFile(filePath, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8"); } async function readPostCoreSourceConfigFile( filePath: string | undefined, options?: { configPath?: string }, ): Promise { if (!filePath) { return undefined; } try { const parsed = parseConfigJson5(await fs.readFile(filePath, "utf-8")); if (!parsed.ok || !isRecord(parsed.parsed)) { return undefined; } return normalizePreUpdateConfigRestoreInput(parsed.parsed, options); } catch { return undefined; } } function normalizePreUpdateConfigRestoreInput( parsed: Record, options?: { configPath?: string }, ): PreUpdateConfigRestoreInput | undefined { const sourceConfig = parsed.sourceConfig; const authoredConfig = parsed.authoredConfig; if (isRecord(sourceConfig) && isRecord(authoredConfig)) { return { sourceConfig: sourceConfig as OpenClawConfig, authoredConfig: authoredConfig as OpenClawConfig, }; } const authored = parsed as OpenClawConfig; return { sourceConfig: options?.configPath ? resolvePreUpdateSourceConfigFromAuthored(authored, options.configPath) : authored, authoredConfig: authored, }; } function resolvePreUpdateSourceConfigFromAuthored( authoredConfig: OpenClawConfig, configPath: string, ): OpenClawConfig { try { const withIncludes = resolveConfigIncludes(authoredConfig, configPath, undefined, { allowedRoots: resolveIncludeRoots(process.env), }); const resolved = resolveConfigEnvVars(withIncludes, process.env, { onMissing: () => undefined, }); return isRecord(resolved) ? (resolved as OpenClawConfig) : authoredConfig; } catch { return authoredConfig; } } async function isFreshPreUpdateConfigSnapshot(params: { currentConfigPath: string; snapshotPath: string; updateStartedAtMs?: number; }): Promise { const snapshotStat = await fs.stat(params.snapshotPath).catch(() => null); if (!snapshotStat) { return false; } if ( params.updateStartedAtMs !== undefined && snapshotStat.mtimeMs + 1000 < params.updateStartedAtMs ) { return false; } if (Date.now() - snapshotStat.mtimeMs > PRE_UPDATE_CONFIG_SNAPSHOT_MAX_AGE_MS) { return false; } const currentStat = await fs.stat(params.currentConfigPath).catch(() => null); return !currentStat || snapshotStat.mtimeMs <= currentStat.mtimeMs + 1000; } export async function readPostCorePreUpdateSourceConfig(params: { sourceConfigPath: string | undefined; currentSnapshot: Awaited>; updateStartedAtMs?: number; }): Promise { const fromChildEnv = await readPostCoreSourceConfigFile(params.sourceConfigPath); if (fromChildEnv) { return fromChildEnv; } if (params.updateStartedAtMs === undefined) { return undefined; } const explicitPreUpdatePath = `${params.currentSnapshot.path}.pre-update`; if ( await isFreshPreUpdateConfigSnapshot({ currentConfigPath: params.currentSnapshot.path, snapshotPath: explicitPreUpdatePath, updateStartedAtMs: params.updateStartedAtMs, }) ) { const preUpdateConfig = await readPostCoreSourceConfigFile(explicitPreUpdatePath, { configPath: params.currentSnapshot.path, }); if ( preUpdateConfig && hasRestorablePreUpdateChannels(params.currentSnapshot, preUpdateConfig) ) { return preUpdateConfig; } return undefined; } const backupPath = `${params.currentSnapshot.path}.bak`; if ( await isFreshPreUpdateConfigSnapshot({ currentConfigPath: params.currentSnapshot.path, snapshotPath: backupPath, updateStartedAtMs: params.updateStartedAtMs, }) ) { const preUpdateConfig = await readPostCoreSourceConfigFile(backupPath, { configPath: params.currentSnapshot.path, }); if ( preUpdateConfig && hasRestorablePreUpdateChannels(params.currentSnapshot, preUpdateConfig) ) { return preUpdateConfig; } } return undefined; }