From a27ccee5d9d5d27f2c368056ee4ad96ae7e46ae6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 00:39:21 +0100 Subject: [PATCH] refactor(config): use source snapshots for config writes --- src/cli/channel-auth.test.ts | 24 +++++++++++++----- src/cli/channel-auth.ts | 19 +++++++++++--- src/cli/directory-cli.test.ts | 21 ++++++++++------ src/cli/directory-cli.ts | 13 +++++++--- src/commands/configure.wizard.ts | 4 ++- src/commands/dashboard.links.test.ts | 8 +++--- src/commands/dashboard.test.ts | 6 +++-- src/commands/dashboard.ts | 2 +- src/commands/doctor-config-preflight.ts | 2 +- src/commands/gateway-install-token.test.ts | 21 ++++++++-------- src/commands/gateway-install-token.ts | 27 ++++++++++++-------- src/commands/models/shared.test.ts | 18 ++++++++------ src/commands/models/shared.ts | 17 +++++++++---- src/commands/onboard-non-interactive.ts | 6 ++++- src/commands/onboard.ts | 2 +- src/config/io.ts | 1 - src/config/mcp-config.ts | 29 ++++++++++++++++------ src/wizard/setup.ts | 6 ++++- 18 files changed, 154 insertions(+), 72 deletions(-) diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 4f0ce7ed8bc..1bd79402b22 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -11,8 +11,9 @@ const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), normalizeChannelId: vi.fn(), loadConfig: vi.fn(), + readConfigFileSnapshot: vi.fn(), applyPluginAutoEnable: vi.fn(), - writeConfigFile: vi.fn(), + replaceConfigFile: vi.fn(), setVerbose: vi.fn(), createClackPrompter: vi.fn(), ensureChannelSetupPluginInstalled: vi.fn(), @@ -44,7 +45,8 @@ vi.mock("../channels/plugins/index.js", () => ({ vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig, - writeConfigFile: mocks.writeConfigFile, + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + replaceConfigFile: mocks.replaceConfigFile, })); vi.mock("../config/plugin-auto-enable.js", () => ({ @@ -84,8 +86,9 @@ describe("channel-auth", () => { mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); mocks.listChannelPluginCatalogEntries.mockReturnValue([]); mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {} } }); + mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); - mocks.writeConfigFile.mockResolvedValue(undefined); + mocks.replaceConfigFile.mockResolvedValue(undefined); mocks.listChannelPlugins.mockReturnValue([plugin]); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); @@ -158,7 +161,10 @@ describe("channel-auth", () => { channelInput: "whatsapp", }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith(autoEnabledCfg); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: autoEnabledCfg, + baseHash: "config-1", + }); }); it("persists auto-enabled config during logout auto-pick too", async () => { @@ -173,7 +179,10 @@ describe("channel-auth", () => { cfg: autoEnabledCfg, }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith(autoEnabledCfg); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: autoEnabledCfg, + baseHash: "config-1", + }); }); it("ignores configured channels that do not support login when channel is omitted", async () => { @@ -304,7 +313,10 @@ describe("channel-auth", () => { workspaceDir: "/tmp/workspace", }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: { whatsapp: {} } }); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: { channels: { whatsapp: {} } }, + baseHash: "config-1", + }); expect(mocks.login).toHaveBeenCalled(); }); diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index 48a2ae61324..83d8e6c04d9 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -5,7 +5,12 @@ import { normalizeChannelId, } from "../channels/plugins/index.js"; import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; -import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js"; +import { + loadConfig, + readConfigFileSnapshot, + replaceConfigFile, + type OpenClawConfig, +} from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { setVerbose } from "../globals.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; @@ -131,6 +136,7 @@ export async function runChannelLogin( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const autoEnabled = applyPluginAutoEnable({ config: loadConfig(), env: process.env, @@ -143,7 +149,10 @@ export async function runChannelLogin( runtime, ); if (autoEnabled.changes.length > 0 || configChanged) { - await writeConfigFile(cfg); + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } const login = plugin.auth?.login; if (!login) { @@ -165,6 +174,7 @@ export async function runChannelLogout( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const autoEnabled = applyPluginAutoEnable({ config: loadConfig(), env: process.env, @@ -177,7 +187,10 @@ export async function runChannelLogout( runtime, ); if (autoEnabled.changes.length > 0 || configChanged) { - await writeConfigFile(cfg); + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } const logoutAccount = plugin.gateway?.logoutAccount; if (!logoutAccount) { diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index 2f34168b26d..563350c4791 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -14,8 +14,9 @@ function getRuntimeCapture(): CliRuntimeCapture { const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(), + readConfigFileSnapshot: vi.fn(), applyPluginAutoEnable: vi.fn(), - writeConfigFile: vi.fn(), + replaceConfigFile: vi.fn(), resolveInstallableChannelPlugin: vi.fn(), resolveMessageChannelSelection: vi.fn(), getChannelPlugin: vi.fn(), @@ -24,7 +25,8 @@ const mocks = vi.hoisted(() => ({ vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig, - writeConfigFile: mocks.writeConfigFile, + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + replaceConfigFile: mocks.replaceConfigFile, })); vi.mock("../config/plugin-auto-enable.js", () => ({ @@ -58,8 +60,9 @@ describe("registerDirectoryCli", () => { vi.clearAllMocks(); getRuntimeCapture().resetRuntimeCapture(); mocks.loadConfig.mockReturnValue({ channels: {} }); + mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); - mocks.writeConfigFile.mockResolvedValue(undefined); + mocks.replaceConfigFile.mockResolvedValue(undefined); mocks.resolveChannelDefaultAccountId.mockReturnValue("default"); mocks.resolveMessageChannelSelection.mockResolvedValue({ channel: "demo-channel", @@ -99,11 +102,12 @@ describe("registerDirectoryCli", () => { allowInstall: true, }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: expect.objectContaining({ plugins: { entries: { "demo-directory": { enabled: true } } }, }), - ); + baseHash: "config-1", + }); expect(self).toHaveBeenCalledWith( expect.objectContaining({ accountId: "default", @@ -150,6 +154,9 @@ describe("registerDirectoryCli", () => { cfg: autoEnabledConfig, }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith(autoEnabledConfig); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: autoEnabledConfig, + baseHash: "config-1", + }); }); }); diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index cfa3108d219..9ac3d3850f6 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.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"; @@ -98,6 +98,7 @@ export function registerDirectoryCli(program: Command) { .option("--json", "Output JSON", false); const resolve = async (opts: { channel?: string; account?: string }) => { + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const autoEnabled = applyPluginAutoEnable({ config: loadConfig(), env: process.env, @@ -115,9 +116,15 @@ export function registerDirectoryCli(program: Command) { : null; if (resolvedExplicit?.configChanged) { cfg = resolvedExplicit.cfg; - await writeConfigFile(cfg); + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } else if (autoEnabled.changes.length > 0) { - await writeConfigFile(cfg); + await replaceConfigFile({ + nextConfig: cfg, + baseHash: (await sourceSnapshotPromise)?.hash, + }); } const selection = explicitChannel ? { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index bd47b51cdab..ea9a440cd4f 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -237,7 +237,9 @@ export async function runConfigureWizard( const prompter = createClackPrompter(); const snapshot = await readConfigFileSnapshot(); - const baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {}; + const baseConfig: OpenClawConfig = snapshot.valid + ? (snapshot.sourceConfig ?? snapshot.config) + : {}; if (snapshot.exists) { const title = snapshot.valid ? "Existing config detected" : "Invalid config"; diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index 153dd5cb237..b14996adca1 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn()); @@ -63,11 +63,9 @@ function mockSnapshot(token: unknown = "abc") { } describe("dashboardCommand", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ dashboardCommand } = await import("./dashboard.js")); - }); - - beforeEach(() => { resetRuntime(); readConfigFileSnapshotMock.mockClear(); resolveGatewayPortMock.mockClear(); diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index e5c1852ccd0..dfab6d700b0 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayBindMode } from "../config/types.gateway.js"; -import { dashboardCommand } from "./dashboard.js"; const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -30,6 +29,7 @@ const runtime = { error: vi.fn(), exit: vi.fn(), }; +let dashboardCommand: typeof import("./dashboard.js").dashboardCommand; function mockSnapshot(params?: { token?: string; @@ -62,7 +62,9 @@ function mockSnapshot(params?: { } describe("dashboardCommand bind selection", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ dashboardCommand } = await import("./dashboard.js")); mocks.readConfigFileSnapshot.mockClear(); mocks.resolveGatewayPort.mockClear(); mocks.resolveControlUiLinks.mockClear(); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 3ca69fbc36b..6957c200cfc 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -52,7 +52,7 @@ export async function dashboardCommand( options: DashboardOptions = {}, ) { const snapshot = await readConfigFileSnapshot(); - const cfg = snapshot.valid ? snapshot.config : {}; + const cfg = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {}; const port = resolveGatewayPort(cfg); const bind = cfg.gateway?.bind ?? "loopback"; const basePath = cfg.gateway?.controlUi?.basePath; diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 26fa997eb58..93c8372da87 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -100,6 +100,6 @@ export async function runDoctorConfigPreflight( return { snapshot, - baseConfig: snapshot.config ?? {}, + baseConfig: snapshot.sourceConfig ?? snapshot.config ?? {}, }; } diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 5946dee10d0..0f5c555af8c 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); +const replaceConfigFileMock = vi.hoisted(() => vi.fn()); const resolveSecretInputRefMock = vi.hoisted(() => vi.fn((): { ref: unknown } => ({ ref: undefined })), ); @@ -29,7 +29,7 @@ const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, - writeConfigFile: writeConfigFileMock, + replaceConfigFile: replaceConfigFileMock, })); vi.mock("../config/types.secrets.js", () => ({ @@ -153,7 +153,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.unavailableReason).toContain("gateway.auth.mode is unset"); expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode token"); expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode password"); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); expect(resolveSecretRefValuesMock).not.toHaveBeenCalled(); }); @@ -171,7 +171,7 @@ describe("resolveGatewayInstallToken", () => { expect( result.warnings.some((message) => message.includes("without saving to config")), ).toBeTruthy(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("persists auto-generated token when requested", async () => { @@ -185,8 +185,9 @@ describe("resolveGatewayInstallToken", () => { }); expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy(); - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ + expect(replaceConfigFileMock).toHaveBeenCalledWith({ + baseHash: undefined, + nextConfig: expect.objectContaining({ gateway: { auth: { mode: "token", @@ -194,7 +195,7 @@ describe("resolveGatewayInstallToken", () => { }, }, }), - ); + }); }); it("drops generated plaintext when config changes to SecretRef before persist", async () => { @@ -227,7 +228,7 @@ describe("resolveGatewayInstallToken", () => { expect( result.warnings.some((message) => message.includes("skipping plaintext token persistence")), ).toBeTruthy(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("does not auto-generate when inferred mode has password SecretRef configured", async () => { @@ -254,7 +255,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("passes the install env through to gateway auth resolution", async () => { @@ -286,7 +287,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("skips token SecretRef resolution when token auth is not required", async () => { diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts index 5664cc8187d..49630a5e967 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -1,5 +1,9 @@ import { formatCliCommand } from "../cli/command-format.js"; -import { readConfigFileSnapshot, writeConfigFile, type OpenClawConfig } from "../config/config.js"; +import { + readConfigFileSnapshot, + replaceConfigFile, + type OpenClawConfig, +} from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; @@ -73,7 +77,7 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { return params.token; } - const baseConfig = snapshot.exists ? snapshot.config : {}; + const baseConfig = snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {}; const existingTokenRef = resolveSecretInputRef({ value: baseConfig.gateway?.auth?.token, defaults: baseConfig.secrets?.defaults, @@ -83,14 +87,17 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { ? undefined : baseConfig.gateway.auth.token.trim() || undefined; if (!existingTokenRef && !baseConfigToken) { - await writeConfigFile({ - ...baseConfig, - gateway: { - ...baseConfig.gateway, - auth: { - ...baseConfig.gateway?.auth, - mode: baseConfig.gateway?.auth?.mode ?? "token", - token: params.token, + await replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { + ...baseConfig, + gateway: { + ...baseConfig.gateway, + auth: { + ...baseConfig.gateway?.auth, + mode: baseConfig.gateway?.auth?.mode ?? "token", + token: params.token, + }, }, }, }); diff --git a/src/commands/models/shared.test.ts b/src/commands/models/shared.test.ts index 2317a105295..df7d7eaf3d1 100644 --- a/src/commands/models/shared.test.ts +++ b/src/commands/models/shared.test.ts @@ -3,12 +3,12 @@ import type { OpenClawConfig } from "../../config/config.js"; const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), - writeConfigFile: vi.fn(), + replaceConfigFile: vi.fn(), })); vi.mock("../../config/config.js", () => ({ readConfigFileSnapshot: (...args: unknown[]) => mocks.readConfigFileSnapshot(...args), - writeConfigFile: (...args: unknown[]) => mocks.writeConfigFile(...args), + replaceConfigFile: (...args: unknown[]) => mocks.replaceConfigFile(...args), })); let loadValidConfigOrThrow: typeof import("./shared.js").loadValidConfigOrThrow; @@ -18,7 +18,7 @@ describe("models/shared", () => { beforeEach(async () => { vi.resetModules(); mocks.readConfigFileSnapshot.mockClear(); - mocks.writeConfigFile.mockClear(); + mocks.replaceConfigFile.mockClear(); ({ loadValidConfigOrThrow, updateConfig } = await import("./shared.js")); }); @@ -26,6 +26,7 @@ describe("models/shared", () => { const cfg = { providers: {} } as unknown as OpenClawConfig; mocks.readConfigFileSnapshot.mockResolvedValue({ valid: true, + runtimeConfig: cfg, config: cfg, }); @@ -48,19 +49,22 @@ describe("models/shared", () => { const cfg = { update: { channel: "stable" } } as unknown as OpenClawConfig; mocks.readConfigFileSnapshot.mockResolvedValue({ valid: true, + hash: "config-1", + sourceConfig: cfg, config: cfg, }); - mocks.writeConfigFile.mockResolvedValue(undefined); + mocks.replaceConfigFile.mockResolvedValue(undefined); await updateConfig((current) => ({ ...current, update: { channel: "beta" }, })); - expect(mocks.writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: expect.objectContaining({ update: { channel: "beta" }, }), - ); + baseHash: "config-1", + }); }); }); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 604b594b613..123197bcb20 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -11,7 +11,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { type OpenClawConfig, readConfigFileSnapshot, - writeConfigFile, + replaceConfigFile, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { toAgentModelListLike } from "../../config/model-input.js"; @@ -70,15 +70,22 @@ export async function loadValidConfigOrThrow(): Promise { const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n"); throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); } - return snapshot.config; + return snapshot.runtimeConfig ?? snapshot.config; } export async function updateConfig( mutator: (cfg: OpenClawConfig) => OpenClawConfig, ): Promise { - const config = await loadValidConfigOrThrow(); - const next = mutator(config); - await writeConfigFile(next); + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n"); + throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); + } + const next = mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config)); + await replaceConfigFile({ + nextConfig: next, + baseHash: snapshot.hash, + }); return next; } diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 7d9fbe7c1af..6fa3623641b 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -20,7 +20,11 @@ export async function runNonInteractiveSetup( return; } - const baseConfig: OpenClawConfig = snapshot.valid ? (snapshot.exists ? snapshot.config : {}) : {}; + const baseConfig: OpenClawConfig = snapshot.valid + ? snapshot.exists + ? (snapshot.sourceConfig ?? snapshot.config) + : {} + : {}; const mode = opts.mode ?? "local"; if (mode !== "local" && mode !== "remote") { runtime.error(`Invalid --mode "${String(mode)}" (use local|remote).`); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 914f8c4565a..6507d650e19 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -75,7 +75,7 @@ export async function setupWizardCommand( if (normalizedOpts.reset) { const snapshot = await readConfigFileSnapshot(); - const baseConfig = snapshot.valid ? snapshot.config : {}; + const baseConfig = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {}; const workspaceDefault = normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions"; diff --git a/src/config/io.ts b/src/config/io.ts index 25ba96850c9..65e8d064965 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1951,7 +1951,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw: effectiveRaw, parsed: effectiveParsed, - sourceConfig: coerceConfig(parsedRes.parsed), // Keep the recovered root file payload here when read healing kicked in. sourceConfig: coerceConfig(effectiveParsed), valid: false, diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index eb24e3c0ae4..702c2871969 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -1,11 +1,18 @@ -import { readConfigFileSnapshot, writeConfigFile } from "./io.js"; +import { readSourceConfigSnapshot } from "./io.js"; +import { replaceConfigFile } from "./mutate.js"; import type { OpenClawConfig } from "./types.openclaw.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; export type ConfigMcpServers = Record>; type ConfigMcpReadResult = - | { ok: true; path: string; config: OpenClawConfig; mcpServers: ConfigMcpServers } + | { + ok: true; + path: string; + config: OpenClawConfig; + mcpServers: ConfigMcpServers; + baseHash?: string; + } | { ok: false; path: string; error: string }; type ConfigMcpWriteResult = @@ -34,7 +41,7 @@ export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers } export async function listConfiguredMcpServers(): Promise { - const snapshot = await readConfigFileSnapshot(); + const snapshot = await readSourceConfigSnapshot(); if (!snapshot.valid) { return { ok: false, @@ -42,11 +49,13 @@ export async function listConfiguredMcpServers(): Promise { error: "Config file is invalid; fix it before using MCP config commands.", }; } + const sourceConfig = snapshot.sourceConfig ?? snapshot.resolved; return { ok: true, path: snapshot.path, - config: structuredClone(snapshot.resolved), - mcpServers: normalizeConfiguredMcpServers(snapshot.resolved.mcp?.servers), + config: structuredClone(sourceConfig), + mcpServers: normalizeConfiguredMcpServers(sourceConfig.mcp?.servers), + baseHash: snapshot.hash, }; } @@ -84,7 +93,10 @@ export async function setConfiguredMcpServer(params: { error: `Config invalid after MCP set (${issue.path}: ${issue.message}).`, }; } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + baseHash: loaded.baseHash, + }); return { ok: true, path: loaded.path, @@ -139,7 +151,10 @@ export async function unsetConfiguredMcpServer(params: { error: `Config invalid after MCP unset (${issue.path}: ${issue.message}).`, }; } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + baseHash: loaded.baseHash, + }); return { ok: true, path: loaded.path, diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index a5336d26c10..c8c7588a6d1 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -131,7 +131,11 @@ export async function runSetupWizard( await requireRiskAcknowledgement({ opts, prompter }); const snapshot = await readConfigFileSnapshot(); - let baseConfig: OpenClawConfig = snapshot.valid ? (snapshot.exists ? snapshot.config : {}) : {}; + let baseConfig: OpenClawConfig = snapshot.valid + ? snapshot.exists + ? (snapshot.sourceConfig ?? snapshot.config) + : {} + : {}; if (snapshot.exists && !snapshot.valid) { await prompter.note(onboardHelpers.summarizeExistingConfig(baseConfig), "Invalid config");