From 47216702f4f755db2f9c0971700d0e1b85cd91b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 01:02:10 +0100 Subject: [PATCH] refactor(config): use source snapshots for config mutations --- src/cli/config-cli.ts | 13 ++++++-- src/cli/hooks-cli.ts | 18 ++++++++--- src/cli/plugins-cli.ts | 26 +++++++++++----- src/cli/plugins-install-persist.ts | 14 +++++++-- src/cli/plugins-update-command.ts | 8 +++-- src/cli/update-cli/update-command.ts | 13 ++++++-- src/commands/agents.command-shared.ts | 9 +++++- src/commands/agents.commands.add.ts | 20 ++++++++---- src/commands/agents.commands.bind.ts | 31 +++++++++++++------ src/commands/agents.commands.delete.ts | 15 ++++++--- src/commands/agents.commands.identity.ts | 15 ++++++--- src/commands/channels/add.ts | 20 ++++++++---- src/commands/channels/capabilities.ts | 12 +++++-- src/commands/channels/remove.ts | 21 +++++++++---- src/commands/channels/resolve.ts | 8 +++-- src/commands/channels/shared.ts | 6 +++- src/commands/config-validation.ts | 21 ++++++++++--- src/commands/configure.wizard.ts | 15 +++++++-- src/commands/onboard-non-interactive.ts | 4 +-- src/commands/onboard-non-interactive/local.ts | 10 ++++-- .../onboard-non-interactive/remote.ts | 10 ++++-- src/gateway/server.impl.ts | 1 + src/gateway/startup-auth.ts | 8 +++-- 23 files changed, 233 insertions(+), 85 deletions(-) diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 6da09bba9ae..d70ba459d86 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import JSON5 from "json5"; import type { OpenClawConfig } from "../config/config.js"; -import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; @@ -1077,7 +1077,10 @@ export async function runConfigSet(opts: { return; } - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); if (removedGatewayAuthPaths.length > 0) { runtime.log( info( @@ -1155,7 +1158,11 @@ export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv runtime.exit(1); return; } - await writeConfigFile(next, { unsetPaths: [parsedPath] }); + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + writeOptions: { unsetPaths: [parsedPath] }, + }); runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`)); } catch (err) { runtime.error(danger(String(err))); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 26a04e97076..e3ab14f82e6 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/io.js"; +import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { buildWorkspaceHookStatus, type HookStatusEntry, @@ -417,7 +417,8 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio } export async function enableHook(hookName: string): Promise { - const config = loadConfig(); + const snapshot = await readConfigFileSnapshot(); + const config = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const hook = resolveHookForToggle(buildHooksReport(config), hookName, { requireEligible: true }); const nextConfig = buildConfigWithHookEnabled({ config, @@ -426,18 +427,25 @@ export async function enableHook(hookName: string): Promise { ensureHooksEnabled: true, }); - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); defaultRuntime.log( `${theme.success("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`, ); } export async function disableHook(hookName: string): Promise { - const config = loadConfig(); + const snapshot = await readConfigFileSnapshot(); + const config = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const hook = resolveHookForToggle(buildHooksReport(config), hookName); const nextConfig = buildConfigWithHookEnabled({ config, hookName, enabled: false }); - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); defaultRuntime.log( `${theme.warn("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`, ); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b446eddd5a4..c5fe067e8ff 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; @@ -542,12 +542,16 @@ export function registerPluginsCli(program: Command) { .description("Enable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { - const cfg = loadConfig(); + const snapshot = await readConfigFileSnapshot(); + const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const enableResult = enablePluginInConfig(cfg, id); let next: OpenClawConfig = enableResult.config; const slotResult = applySlotSelectionForPlugin(next, id); next = slotResult.config; - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); logSlotWarnings(slotResult.warnings); if (enableResult.enabled) { defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`); @@ -565,9 +569,13 @@ export function registerPluginsCli(program: Command) { .description("Disable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { - const cfg = loadConfig(); + const snapshot = await readConfigFileSnapshot(); + const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const next = setPluginEnabledInConfig(cfg, id, false); - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); defaultRuntime.log(`Disabled plugin "${id}". Restart the gateway to apply.`); }); @@ -580,7 +588,8 @@ export function registerPluginsCli(program: Command) { .option("--force", "Skip confirmation prompt", false) .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { - const cfg = loadConfig(); + const snapshot = await readConfigFileSnapshot(); + const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const report = buildPluginStatusReport({ config: cfg }); const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions"); const keepFiles = Boolean(opts.keepFiles || opts.keepConfig); @@ -686,7 +695,10 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(theme.warn(warning)); } - await writeConfigFile(result.config); + await replaceConfigFile({ + nextConfig: result.config, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); const removed: string[] = []; if (result.actions.entry) { diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index c1163d14cd9..fe6839af7a0 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; -import { writeConfigFile } from "../config/config.js"; +import { replaceConfigFile } from "../config/config.js"; import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { type PluginInstallUpdate, recordPluginInstall } from "../plugins/installs.js"; @@ -14,6 +14,7 @@ import { export async function persistPluginInstall(params: { config: OpenClawConfig; + baseHash?: string; pluginId: string; install: Omit; successMessage?: string; @@ -26,7 +27,10 @@ export async function persistPluginInstall(params: { }); const slotResult = applySlotSelectionForPlugin(next, params.pluginId); next = slotResult.config; - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + }); logSlotWarnings(slotResult.warnings); if (params.warningMessage) { defaultRuntime.log(theme.warn(params.warningMessage)); @@ -38,6 +42,7 @@ export async function persistPluginInstall(params: { export async function persistHookPackInstall(params: { config: OpenClawConfig; + baseHash?: string; hookPackId: string; hooks: string[]; install: Omit; @@ -49,7 +54,10 @@ export async function persistHookPackInstall(params: { hooks: params.hooks, ...params.install, }); - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + }); defaultRuntime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`); logHookPackRestartHint(); return next; diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 668dd2da212..4e4eef2f7d0 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -1,4 +1,4 @@ -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import type { HookInstallRecord } from "../config/types.hooks.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { updateNpmInstalledHookPacks } from "../hooks/update.js"; @@ -91,6 +91,7 @@ export async function runPluginUpdateCommand(params: { id?: string; opts: { all?: boolean; dryRun?: boolean }; }) { + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const cfg = loadConfig(); const logger = { info: (msg: string) => defaultRuntime.log(msg), @@ -184,7 +185,10 @@ export async function runPluginUpdateCommand(params: { } if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) { - await writeConfigFile(hookResult.config); + await replaceConfigFile({ + nextConfig: hookResult.config, + baseHash: (await sourceSnapshotPromise)?.hash, + }); defaultRuntime.log("Restart the gateway to load plugins and hooks."); } } diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 4d767750ee4..58c63fd288b 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -7,8 +7,8 @@ import { import { doctorCommand } from "../../commands/doctor.js"; import { readConfigFileSnapshot, + replaceConfigFile, resolveGatewayPort, - writeConfigFile, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js"; @@ -550,7 +550,10 @@ async function updatePluginsAfterCoreUpdate(params: { pluginConfig = npmResult.config; if (syncResult.changed || npmResult.changed) { - await writeConfigFile(pluginConfig); + await replaceConfigFile({ + nextConfig: pluginConfig, + baseHash: params.configSnapshot.hash, + }); } if (params.opts.json) { @@ -1020,9 +1023,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { channel: requestedChannel, }, }; - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + baseHash: configSnapshot.hash, + }); postUpdateConfigSnapshot = { ...configSnapshot, + hash: undefined, parsed: next, sourceConfig: asResolvedSourceConfig(next), resolved: asResolvedSourceConfig(next), diff --git a/src/commands/agents.command-shared.ts b/src/commands/agents.command-shared.ts index 92aeda9946c..93a5f60eff6 100644 --- a/src/commands/agents.command-shared.ts +++ b/src/commands/agents.command-shared.ts @@ -1,11 +1,18 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; -import { requireValidConfigSnapshot } from "./config-validation.js"; +import { + requireValidConfigFileSnapshot as requireValidConfigFileSnapshotBase, + requireValidConfigSnapshot, +} from "./config-validation.js"; export function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv { return { ...runtime, log: () => {} }; } +export async function requireValidConfigFileSnapshot(runtime: RuntimeEnv) { + return await requireValidConfigFileSnapshotBase(runtime); +} + export async function requireValidConfig(runtime: RuntimeEnv): Promise { return await requireValidConfigSnapshot(runtime); } diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 63704308171..1fb71793eb8 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -7,7 +7,7 @@ import { } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; -import { writeConfigFile } from "../config/config.js"; +import { replaceConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -21,7 +21,7 @@ import { describeBinding, parseBindingSpecs, } from "./agents.bindings.js"; -import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.js"; +import { createQuietRuntime, requireValidConfigFileSnapshot } from "./agents.command-shared.js"; import { applyAgentConfig, findAgentEntryIndex, listAgentEntries } from "./agents.config.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; @@ -53,10 +53,12 @@ export async function agentsAddCommand( runtime: RuntimeEnv = defaultRuntime, params?: { hasFlags?: boolean }, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const configSnapshot = await requireValidConfigFileSnapshot(runtime); + if (!configSnapshot) { return; } + const cfg = configSnapshot.sourceConfig ?? configSnapshot.config; + const baseHash = configSnapshot.hash; const workspaceFlag = opts.workspace?.trim(); const nameInput = opts.name?.trim(); @@ -127,7 +129,10 @@ export async function agentsAddCommand( ? applyAgentBindings(nextConfig, bindingParse.bindings) : { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] }; - await writeConfigFile(bindingResult.config); + await replaceConfigFile({ + nextConfig: bindingResult.config, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (!opts.json) { logConfigUpdated(runtime); } @@ -342,7 +347,10 @@ export async function agentsAddCommand( } } - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); logConfigUpdated(runtime); await ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts index d436ddd6556..ab12c45e96d 100644 --- a/src/commands/agents.commands.bind.ts +++ b/src/commands/agents.commands.bind.ts @@ -1,6 +1,6 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isRouteBinding, listRouteBindings } from "../config/bindings.js"; -import { writeConfigFile } from "../config/config.js"; +import { replaceConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import type { AgentRouteBinding } from "../config/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -12,7 +12,7 @@ import { parseBindingSpecs, removeAgentBindings, } from "./agents.bindings.js"; -import { requireValidConfig } from "./agents.command-shared.js"; +import { requireValidConfig, requireValidConfigFileSnapshot } from "./agents.command-shared.js"; import { buildAgentSummaries } from "./agents.config.js"; type AgentsBindingsListOptions = { @@ -135,11 +135,13 @@ async function resolveConfigAndTargetAgentIdOrExit(params: { }): Promise<{ cfg: NonNullable>>; agentId: string; + baseHash?: string; } | null> { - const cfg = await requireValidConfig(params.runtime); - if (!cfg) { + const configSnapshot = await requireValidConfigFileSnapshot(params.runtime); + if (!configSnapshot) { return null; } + const cfg = configSnapshot.sourceConfig ?? configSnapshot.config; const agentId = resolveTargetAgentIdOrExit({ cfg, runtime: params.runtime, @@ -148,7 +150,7 @@ async function resolveConfigAndTargetAgentIdOrExit(params: { if (!agentId) { return null; } - return { cfg, agentId }; + return { cfg, agentId, baseHash: configSnapshot.hash }; } export async function agentsBindingsCommand( @@ -213,7 +215,7 @@ export async function agentsBindCommand( if (!resolved) { return; } - const { cfg, agentId } = resolved; + const { cfg, agentId, baseHash } = resolved; const parsed = resolveParsedBindingsOrExit({ runtime, @@ -228,7 +230,10 @@ export async function agentsBindCommand( const result = applyAgentBindings(cfg, parsed.bindings); if (result.added.length > 0 || result.updated.length > 0) { - await writeConfigFile(result.config); + await replaceConfigFile({ + nextConfig: result.config, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (!opts.json) { logConfigUpdated(runtime); } @@ -290,7 +295,7 @@ export async function agentsUnbindCommand( if (!resolved) { return; } - const { cfg, agentId } = resolved; + const { cfg, agentId, baseHash } = resolved; if (opts.all && (opts.bind?.length ?? 0) > 0) { runtime.error("Use either --all or --bind, not both."); runtime.exit(1); @@ -311,7 +316,10 @@ export async function agentsUnbindCommand( bindings: [...keptRoutes, ...nonRoutes].length > 0 ? [...keptRoutes, ...nonRoutes] : undefined, }; - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (!opts.json) { logConfigUpdated(runtime); } @@ -341,7 +349,10 @@ export async function agentsUnbindCommand( const result = removeAgentBindings(cfg, parsed.bindings); if (result.removed.length > 0) { - await writeConfigFile(result.config); + await replaceConfigFile({ + nextConfig: result.config, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (!opts.json) { logConfigUpdated(runtime); } diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index 13795bcf779..84421ed60c5 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -1,12 +1,12 @@ import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import { writeConfigFile } from "../config/config.js"; +import { replaceConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; -import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.js"; +import { createQuietRuntime, requireValidConfigFileSnapshot } from "./agents.command-shared.js"; import { findAgentEntryIndex, listAgentEntries, pruneAgentConfig } from "./agents.config.js"; import { moveToTrash } from "./onboard-helpers.js"; @@ -20,10 +20,12 @@ export async function agentsDeleteCommand( opts: AgentsDeleteOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const configSnapshot = await requireValidConfigFileSnapshot(runtime); + if (!configSnapshot) { return; } + const cfg = configSnapshot.sourceConfig ?? configSnapshot.config; + const baseHash = configSnapshot.hash; const input = opts.id?.trim(); if (!input) { @@ -70,7 +72,10 @@ export async function agentsDeleteCommand( const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); const result = pruneAgentConfig(cfg, agentId); - await writeConfigFile(result.config); + await replaceConfigFile({ + nextConfig: result.config, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (!opts.json) { logConfigUpdated(runtime); } diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts index 3329efe25a0..b4d22db5f5e 100644 --- a/src/commands/agents.commands.identity.ts +++ b/src/commands/agents.commands.identity.ts @@ -3,14 +3,14 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js"; import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; -import { writeConfigFile } from "../config/config.js"; +import { replaceConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import type { IdentityConfig } from "../config/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; -import { requireValidConfig } from "./agents.command-shared.js"; +import { requireValidConfigFileSnapshot } from "./agents.command-shared.js"; import { type AgentIdentity, findAgentEntryIndex, @@ -69,10 +69,12 @@ export async function agentsSetIdentityCommand( opts: AgentsSetIdentityOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const configSnapshot = await requireValidConfigFileSnapshot(runtime); + if (!configSnapshot) { return; } + const cfg = configSnapshot.sourceConfig ?? configSnapshot.config; + const baseHash = configSnapshot.hash; const agentRaw = coerceTrimmed(opts.agent); const nameRaw = coerceTrimmed(opts.name); @@ -195,7 +197,10 @@ export async function agentsSetIdentityCommand( }, }; - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (opts.json) { writeRuntimeJson(runtime, { diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index a96fd8eaa85..401abf01873 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -5,7 +5,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; -import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; +import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; @@ -17,7 +17,7 @@ import { } from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; -import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; +import { channelLabel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; export type ChannelsAddOptions = { channel?: string; @@ -46,10 +46,12 @@ export async function channelsAddCommand( runtime: RuntimeEnv = defaultRuntime, params?: { hasFlags?: boolean }, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const configSnapshot = await requireValidConfigFileSnapshot(runtime); + if (!configSnapshot) { return; } + const cfg = (configSnapshot.sourceConfig ?? configSnapshot.config) as OpenClawConfig; + const baseHash = configSnapshot.hash; let nextConfig = cfg; const useWizard = shouldUseWizard(params); @@ -177,7 +179,10 @@ export async function channelsAddCommand( } } - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); await runCollectedChannelOnboardingPostWriteHooks({ hooks: postWriteHooks.drain(), cfg: nextConfig, @@ -348,7 +353,10 @@ export async function channelsAddCommand( runtime, }); - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; if (afterAccountConfigWritten) { diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 1fc7e69beac..63eb8108d20 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -10,7 +10,11 @@ import type { ChannelCapabilitiesDisplayLine, ChannelPlugin, } from "../../channels/plugins/types.js"; -import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; +import { + readConfigFileSnapshot, + replaceConfigFile, + type OpenClawConfig, +} from "../../config/config.js"; import { danger } from "../../globals.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; @@ -207,6 +211,7 @@ export async function channelsCapabilitiesCommand( opts: ChannelsCapabilitiesOptions, runtime: RuntimeEnv = defaultRuntime, ) { + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const loadedCfg = await requireValidConfig(runtime); if (!loadedCfg) { return; @@ -240,7 +245,10 @@ export async function channelsCapabilitiesCommand( }); if (resolved.configChanged) { cfg = resolved.cfg; - await writeConfigFile(cfg); + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } return resolved.plugin ? [resolved.plugin] : null; })(); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 4b508bf4f33..8662c041b97 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -4,12 +4,17 @@ import { listChannelPlugins, normalizeChannelId, } from "../../channels/plugins/index.js"; -import { type OpenClawConfig, writeConfigFile } from "../../config/config.js"; +import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; -import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; +import { + type ChatChannel, + channelLabel, + requireValidConfigFileSnapshot, + shouldUseWizard, +} from "./shared.js"; export type ChannelsRemoveOptions = { channel?: string; @@ -30,11 +35,12 @@ export async function channelsRemoveCommand( runtime: RuntimeEnv = defaultRuntime, params?: { hasFlags?: boolean }, ) { - const loadedCfg = await requireValidConfig(runtime); - if (!loadedCfg) { + const configSnapshot = await requireValidConfigFileSnapshot(runtime); + if (!configSnapshot) { return; } - let cfg = loadedCfg; + const baseHash = configSnapshot.hash; + let cfg = (configSnapshot.sourceConfig ?? configSnapshot.config) as OpenClawConfig; const useWizard = shouldUseWizard(params); const prompter = useWizard ? createClackPrompter() : null; @@ -160,7 +166,10 @@ export async function channelsRemoveCommand( }); } - await writeConfigFile(next); + await replaceConfigFile({ + nextConfig: next, + ...(baseHash !== undefined ? { baseHash } : {}), + }); if (useWizard && prompter) { await prompter.outro( deleteConfig diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index e1cbc729ac3..ca0e8de2567 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -2,7 +2,7 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js"; import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; -import { loadConfig, writeConfigFile } from "../../config/config.js"; +import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; @@ -72,6 +72,7 @@ function formatResolveResult(result: ResolveResult): string { } export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) { + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const loadedRaw = loadConfig(); const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, @@ -103,7 +104,10 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti : null; if (resolvedExplicit?.configChanged) { cfg = resolvedExplicit.cfg; - await writeConfigFile(cfg); + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } const selection = explicitChannel diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index d1e9b378518..9ea60786390 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -7,11 +7,15 @@ import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { requireValidConfigSnapshot } from "../config-validation.js"; +import { + requireValidConfigFileSnapshot, + requireValidConfigSnapshot, +} from "../config-validation.js"; export type ChatChannel = ChannelId; export { requireValidConfigSnapshot }; +export { requireValidConfigFileSnapshot }; export async function requireValidConfig( runtime: RuntimeEnv = defaultRuntime, diff --git a/src/commands/config-validation.ts b/src/commands/config-validation.ts index 97c1ffc665e..4a80ab1f6ad 100644 --- a/src/commands/config-validation.ts +++ b/src/commands/config-validation.ts @@ -1,5 +1,9 @@ import { formatCliCommand } from "../cli/command-format.js"; -import { type OpenClawConfig, readConfigFileSnapshot } from "../config/config.js"; +import { + type ConfigFileSnapshot, + type OpenClawConfig, + readConfigFileSnapshot, +} from "../config/config.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { buildPluginCompatibilityNotices, @@ -7,10 +11,10 @@ import { } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; -export async function requireValidConfigSnapshot( +export async function requireValidConfigFileSnapshot( runtime: RuntimeEnv, opts?: { includeCompatibilityAdvisory?: boolean }, -): Promise { +): Promise { const snapshot = await readConfigFileSnapshot(); if (snapshot.exists && !snapshot.valid) { const issues = @@ -23,7 +27,7 @@ export async function requireValidConfigSnapshot( return null; } if (opts?.includeCompatibilityAdvisory !== true) { - return snapshot.config; + return snapshot; } const compatibility = buildPluginCompatibilityNotices({ config: snapshot.config }); if (compatibility.length > 0) { @@ -38,5 +42,12 @@ export async function requireValidConfigSnapshot( ].join("\n"), ); } - return snapshot.config; + return snapshot; +} + +export async function requireValidConfigSnapshot( + runtime: RuntimeEnv, + opts?: { includeCompatibilityAdvisory?: boolean }, +): Promise { + return (await requireValidConfigFileSnapshot(runtime, opts))?.config ?? null; } diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index ea9a440cd4f..ce2ef5c39f3 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -2,7 +2,7 @@ import fsPromises from "node:fs/promises"; import nodePath from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; -import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; +import { readConfigFileSnapshot, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -237,6 +237,7 @@ export async function runConfigureWizard( const prompter = createClackPrompter(); const snapshot = await readConfigFileSnapshot(); + let currentBaseHash = snapshot.hash; const baseConfig: OpenClawConfig = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {}; @@ -323,7 +324,11 @@ export async function runConfigureWizard( command: opts.command, mode, }); - await writeConfigFile(remoteConfig); + await replaceConfigFile({ + nextConfig: remoteConfig, + ...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}), + }); + currentBaseHash = undefined; logConfigUpdated(runtime); outro("Remote gateway configured."); return; @@ -352,7 +357,11 @@ export async function runConfigureWizard( command: opts.command, mode, }); - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}), + }); + currentBaseHash = undefined; logConfigUpdated(runtime); }; diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 6fa3623641b..0562ca6507f 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -33,9 +33,9 @@ export async function runNonInteractiveSetup( } if (mode === "remote") { - await runNonInteractiveRemoteSetup({ opts, runtime, baseConfig }); + await runNonInteractiveRemoteSetup({ opts, runtime, baseConfig, baseHash: snapshot.hash }); return; } - await runNonInteractiveLocalSetup({ opts, runtime, baseConfig }); + await runNonInteractiveLocalSetup({ opts, runtime, baseConfig, baseHash: snapshot.hash }); } diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index f14aae65eec..807557b3d79 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -1,6 +1,6 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveGatewayPort, writeConfigFile } from "../../config/config.js"; +import { replaceConfigFile, resolveGatewayPort } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import type { RuntimeEnv } from "../../runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js"; @@ -71,8 +71,9 @@ export async function runNonInteractiveLocalSetup(params: { opts: OnboardOptions; runtime: RuntimeEnv; baseConfig: OpenClawConfig; + baseHash?: string; }) { - const { opts, runtime, baseConfig } = params; + const { opts, runtime, baseConfig, baseHash } = params; const mode = "local" as const; const workspaceDir = resolveNonInteractiveWorkspaceDir({ @@ -126,7 +127,10 @@ export async function runNonInteractiveLocalSetup(params: { nextConfig = applyNonInteractiveSkillsConfig({ nextConfig, opts, runtime }); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); logConfigUpdated(runtime); await ensureWorkspaceAndSessions(workspaceDir, runtime, { diff --git a/src/commands/onboard-non-interactive/remote.ts b/src/commands/onboard-non-interactive/remote.ts index aac22302cbc..29d3b105ced 100644 --- a/src/commands/onboard-non-interactive/remote.ts +++ b/src/commands/onboard-non-interactive/remote.ts @@ -1,6 +1,6 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { writeConfigFile } from "../../config/config.js"; +import { replaceConfigFile } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { applyWizardMetadata } from "../onboard-helpers.js"; @@ -10,8 +10,9 @@ export async function runNonInteractiveRemoteSetup(params: { opts: OnboardOptions; runtime: RuntimeEnv; baseConfig: OpenClawConfig; + baseHash?: string; }) { - const { opts, runtime, baseConfig } = params; + const { opts, runtime, baseConfig, baseHash } = params; const mode = "remote" as const; const remoteUrl = opts.remoteUrl?.trim(); @@ -33,7 +34,10 @@ export async function runNonInteractiveRemoteSetup(params: { }, }; nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); logConfigUpdated(runtime); const payload = { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 9c18f3c12be..cff3f6bec37 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -295,6 +295,7 @@ async function prepareGatewayStartupConfig(params: { authOverride: params.authOverride, tailscaleOverride: params.tailscaleOverride, persist: true, + baseHash: params.configSnapshot.hash, }); const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, { auth: params.authOverride, diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index c3995ed2d3d..cbf2c19444c 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -4,7 +4,7 @@ import type { GatewayTailscaleConfig, OpenClawConfig, } from "../config/config.js"; -import { writeConfigFile } from "../config/config.js"; +import { replaceConfigFile } from "../config/config.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy.js"; import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js"; @@ -221,6 +221,7 @@ export async function ensureGatewayStartupAuth(params: { authOverride?: GatewayAuthConfig; tailscaleOverride?: GatewayTailscaleConfig; persist?: boolean; + baseHash?: string; }): Promise<{ cfg: OpenClawConfig; auth: ReturnType; @@ -270,7 +271,10 @@ export async function ensureGatewayStartupAuth(params: { resolvedAuth: resolved, }); if (persist) { - await writeConfigFile(nextCfg); + await replaceConfigFile({ + nextConfig: nextCfg, + baseHash: params.baseHash, + }); } const nextAuth = resolveGatewayAuthFromConfig({