mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 20:31:19 +00:00
refactor(config): use source snapshots for config writes
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,6 +100,6 @@ export async function runDoctorConfigPreflight(
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
baseConfig: snapshot.config ?? {},
|
||||
baseConfig: snapshot.sourceConfig ?? snapshot.config ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user