refactor(config): use source snapshots for config mutations

This commit is contained in:
Peter Steinberger 2026-03-30 01:02:10 +01:00
parent f9bf76067f
commit 47216702f4
No known key found for this signature in database
23 changed files with 233 additions and 85 deletions

View File

@ -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)));

View File

@ -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<void> {
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<void> {
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<void> {
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)}`,
);

View File

@ -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("<id>", "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("<id>", "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) {

View File

@ -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<PluginInstallUpdate, "pluginId">;
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<HookInstallUpdate, "hookId" | "hooks">;
@ -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;

View File

@ -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.");
}
}

View File

@ -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<void> {
channel: requestedChannel,
},
};
await writeConfigFile(next);
await replaceConfigFile({
nextConfig: next,
baseHash: configSnapshot.hash,
});
postUpdateConfigSnapshot = {
...configSnapshot,
hash: undefined,
parsed: next,
sourceConfig: asResolvedSourceConfig(next),
resolved: asResolvedSourceConfig(next),

View File

@ -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<OpenClawConfig | null> {
return await requireValidConfigSnapshot(runtime);
}

View File

@ -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),

View File

@ -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<Awaited<ReturnType<typeof requireValidConfig>>>;
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);
}

View File

@ -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);
}

View File

@ -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, {

View File

@ -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) {

View File

@ -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;
})();

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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<OpenClawConfig | null> {
): Promise<ConfigFileSnapshot | null> {
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<OpenClawConfig | null> {
return (await requireValidConfigFileSnapshot(runtime, opts))?.config ?? null;
}

View File

@ -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);
};

View File

@ -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 });
}

View File

@ -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, {

View File

@ -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 = {

View File

@ -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,

View File

@ -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<typeof resolveGatewayAuth>;
@ -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({