refactor(config): use source snapshots for config writes

This commit is contained in:
Peter Steinberger
2026-03-30 00:39:21 +01:00
parent c5baf63fa5
commit a27ccee5d9
18 changed files with 154 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,6 +100,6 @@ export async function runDoctorConfigPreflight(
return {
snapshot,
baseConfig: snapshot.config ?? {},
baseConfig: snapshot.sourceConfig ?? snapshot.config ?? {},
};
}

View File

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

View File

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

View File

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

View File

@@ -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<OpenClawConfig> {
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<OpenClawConfig> {
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;
}

View File

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

View File

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

View File

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

View File

@@ -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<string, Record<string, unknown>>;
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<ConfigMcpReadResult> {
const snapshot = await readConfigFileSnapshot();
const snapshot = await readSourceConfigSnapshot();
if (!snapshot.valid) {
return {
ok: false,
@@ -42,11 +49,13 @@ export async function listConfiguredMcpServers(): Promise<ConfigMcpReadResult> {
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,

View File

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