diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index c1440a65894..df6fe7d4a75 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -7,7 +7,7 @@ const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); -const replaceConfigFileMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false)); const resolveSecretInputRefMock = vi.hoisted(() => vi.fn((): { ref: unknown } => ({ ref: undefined })), @@ -80,7 +80,11 @@ vi.mock("../../config/paths.js", () => ({ vi.mock("../../commands/gateway-install-token.persist.runtime.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, - replaceConfigFile: replaceConfigFileMock, + readConfigFileSnapshotForWrite: vi.fn(async () => ({ + snapshot: await readConfigFileSnapshotMock(), + writeOptions: { expectedConfigPath: "/tmp/openclaw.json" }, + })), + writeConfigFile: writeConfigFileMock, })); vi.mock("../../config/types.secrets.js", () => ({ @@ -172,7 +176,7 @@ describe("runDaemonInstall", () => { resolveNodeStartupTlsEnvironmentMock.mockReset(); readConfigFileSnapshotMock.mockReset(); resolveGatewayPortMock.mockClear(); - replaceConfigFileMock.mockReset(); + writeConfigFileMock.mockReset(); resolveIsNixModeMock.mockReset(); resolveSecretInputRefMock.mockReset(); resolveGatewayAuthMock.mockReset(); @@ -251,7 +255,7 @@ describe("runDaemonInstall", () => { expect(actionState.failed).toEqual([]); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); expectFirstInstallPlanCallOmitsToken(); - expect(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); expect( actionState.warnings.some((warning) => warning.includes("gateway.auth.token is SecretRef-managed"), @@ -285,13 +289,11 @@ describe("runDaemonInstall", () => { await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); - expect(replaceConfigFileMock).toHaveBeenCalledTimes(1); - const writtenConfig = replaceConfigFileMock.mock.calls[0]?.[0] as { - nextConfig?: { - gateway?: { auth?: { token?: string } }; - }; + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as { + gateway?: { auth?: { token?: string } }; }; - expect(writtenConfig.nextConfig?.gateway?.auth?.token).toBe("minted-token"); + expect(writtenConfig.gateway?.auth?.token).toBe("minted-token"); expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( expect.objectContaining({ port: 18789 }), ); diff --git a/src/commands/gateway-install-token.persist.runtime.ts b/src/commands/gateway-install-token.persist.runtime.ts index 5d819e66d92..e553c2c93c3 100644 --- a/src/commands/gateway-install-token.persist.runtime.ts +++ b/src/commands/gateway-install-token.persist.runtime.ts @@ -1,2 +1,5 @@ -export { readConfigFileSnapshot } from "../config/io.js"; -export { replaceConfigFile } from "../config/mutate.js"; +export { + readConfigFileSnapshot, + readConfigFileSnapshotForWrite, + writeConfigFile, +} from "../config/io.js"; diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index c12f70a0c12..6df9d3afa36 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -3,7 +3,8 @@ import type { OpenClawConfig } from "../config/types.js"; import { resolveGatewayInstallToken } from "./gateway-install-token.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); -const replaceConfigFileMock = vi.hoisted(() => vi.fn()); +const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); const resolveSecretInputRefMock = vi.hoisted(() => vi.fn((): { ref: unknown } => ({ ref: undefined })), ); @@ -30,7 +31,8 @@ const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); vi.mock("./gateway-install-token.persist.runtime.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, - replaceConfigFile: replaceConfigFileMock, + readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock, + writeConfigFile: writeConfigFileMock, })); vi.mock("../config/types.secrets.js", () => ({ @@ -62,6 +64,10 @@ describe("resolveGatewayInstallToken", () => { beforeEach(() => { vi.clearAllMocks(); readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} }); + readConfigFileSnapshotForWriteMock.mockImplementation(async () => ({ + snapshot: await readConfigFileSnapshotMock(), + writeOptions: {}, + })); resolveSecretInputRefMock.mockReturnValue({ ref: undefined }); hasConfiguredSecretInputMock.mockImplementation((value: unknown) => { if (typeof value === "string") { @@ -152,7 +158,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(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); expect(resolveSecretRefValuesMock).not.toHaveBeenCalled(); }); @@ -170,7 +176,7 @@ describe("resolveGatewayInstallToken", () => { expect( result.warnings.some((message) => message.includes("without saving to config")), ).toBeTruthy(); - expect(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); }); it("persists auto-generated token when requested", async () => { @@ -184,9 +190,8 @@ describe("resolveGatewayInstallToken", () => { }); expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy(); - expect(replaceConfigFileMock).toHaveBeenCalledWith({ - baseHash: undefined, - nextConfig: expect.objectContaining({ + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ gateway: { auth: { mode: "token", @@ -194,7 +199,8 @@ describe("resolveGatewayInstallToken", () => { }, }, }), - }); + expect.objectContaining({ baseSnapshot: expect.any(Object) }), + ); }); it("drops generated plaintext when config changes to SecretRef before persist", async () => { @@ -227,7 +233,7 @@ describe("resolveGatewayInstallToken", () => { expect( result.warnings.some((message) => message.includes("skipping plaintext token persistence")), ).toBeTruthy(); - expect(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); }); it("does not auto-generate when inferred mode has password SecretRef configured", async () => { @@ -254,7 +260,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); - expect(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); }); it("passes the install env through to gateway auth resolution", async () => { @@ -286,7 +292,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); - expect(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(writeConfigFileMock).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 5a2db419cb1..3304963eb90 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -9,8 +9,8 @@ import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { - readConfigFileSnapshot, - replaceConfigFile, + readConfigFileSnapshotForWrite, + writeConfigFile, } from "./gateway-install-token.persist.runtime.js"; import { randomToken } from "./random-token.js"; @@ -39,7 +39,14 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { warnings: string[]; }): Promise { try { - const snapshot = params.configSnapshot ?? (await readConfigFileSnapshot()); + const prepared = + params.configSnapshot && params.configWriteOptions + ? { + snapshot: params.configSnapshot, + writeOptions: params.configWriteOptions, + } + : await readConfigFileSnapshotForWrite(); + const snapshot = params.configSnapshot ?? prepared.snapshot; if (snapshot.exists && !snapshot.valid) { params.warnings.push( "Warning: config file exists but is invalid; skipping token persistence.", @@ -57,9 +64,8 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { ? undefined : normalizeOptionalString(baseConfig.gateway.auth.token); if (!existingTokenRef && !baseConfigToken) { - await replaceConfigFile({ - baseHash: snapshot.hash, - nextConfig: { + await writeConfigFile( + { ...baseConfig, gateway: { ...baseConfig.gateway, @@ -70,9 +76,12 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { }, }, }, - snapshot: params.configSnapshot, - writeOptions: params.configWriteOptions, - }); + { + baseSnapshot: snapshot, + ...prepared.writeOptions, + ...params.configWriteOptions, + }, + ); return params.token; } if (baseConfigToken) {