From cac05362a9996f7610d4c179474ba14fbc9b5da6 Mon Sep 17 00:00:00 2001 From: OwenYWT Date: Thu, 9 Apr 2026 11:23:23 +0800 Subject: [PATCH] fix(whatsapp): write creds.json atomically --- extensions/whatsapp/src/session.runtime.ts | 1 + extensions/whatsapp/src/session.test.ts | 94 +++++++++++++++------- extensions/whatsapp/src/session.ts | 34 +++++++- test/mocks/baileys.ts | 8 ++ 4 files changed, 109 insertions(+), 28 deletions(-) diff --git a/extensions/whatsapp/src/session.runtime.ts b/extensions/whatsapp/src/session.runtime.ts index 08f0eb55441..3a3762d17c2 100644 --- a/extensions/whatsapp/src/session.runtime.ts +++ b/extensions/whatsapp/src/session.runtime.ts @@ -1,4 +1,5 @@ export { + BufferJSON, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index ad140f21517..9171765c4dd 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -12,6 +12,7 @@ let formatError: typeof import("./session.js").formatError; let logWebSelfId: typeof import("./session.js").logWebSelfId; let waitForWaConnection: typeof import("./session.js").waitForWaConnection; let waitForCredsSaveQueue: typeof import("./session.js").waitForCredsSaveQueue; +let writeCredsJsonAtomically: typeof import("./session.js").writeCredsJsonAtomically; async function flushCredsUpdate() { await new Promise((resolve) => setImmediate(resolve)); @@ -19,10 +20,8 @@ async function flushCredsUpdate() { async function emitCredsUpdateAndReadSaveCreds() { const sock = getLastSocket(); - const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; sock.ev.emit("creds.update", {}); await flushCredsUpdate(); - return saveCreds; } function mockCredsJsonSpies(readContents: string) { @@ -81,8 +80,14 @@ function mockLogWebSelfIdCreds(me: Record) { describe("web session", () => { beforeAll(async () => { - ({ createWaSocket, formatError, logWebSelfId, waitForWaConnection, waitForCredsSaveQueue } = - await import("./session.js")); + ({ + createWaSocket, + formatError, + logWebSelfId, + waitForWaConnection, + waitForCredsSaveQueue, + writeCredsJsonAtomically, + } = await import("./session.js")); }); beforeEach(() => { @@ -100,6 +105,8 @@ describe("web session", () => { }); it("creates WA socket with QR handler", async () => { + const writeFileSpy = vi.spyOn(fsSync.promises, "writeFile").mockResolvedValue(undefined); + vi.spyOn(fsSync.promises, "rename").mockResolvedValue(undefined); await createWaSocket(true, false); const makeWASocket = baileys.makeWASocket as ReturnType; expect(makeWASocket).toHaveBeenCalledWith( @@ -110,11 +117,10 @@ describe("web session", () => { expect(passedLogger?.level).toBe("silent"); expect(typeof passedLogger?.trace).toBe("function"); const sock = getLastSocket(); - const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; // trigger creds.update listener sock.ev.emit("creds.update", {}); await flushCredsUpdate(); - expect(saveCreds).toHaveBeenCalled(); + expect(writeFileSpy).toHaveBeenCalled(); }); it("uses ambient env proxy agent when HTTPS_PROXY is configured", async () => { @@ -233,12 +239,14 @@ describe("web session", () => { it("does not clobber creds backup when creds.json is corrupted", async () => { const creds = mockCredsJsonSpies("{"); + const writeFileSpy = vi.spyOn(fsSync.promises, "writeFile").mockResolvedValue(undefined); + vi.spyOn(fsSync.promises, "rename").mockResolvedValue(undefined); await createWaSocket(false, false); - const saveCreds = await emitCredsUpdateAndReadSaveCreds(); + await emitCredsUpdateAndReadSaveCreds(); expect(creds.copySpy).not.toHaveBeenCalled(); - expect(saveCreds).toHaveBeenCalled(); + expect(writeFileSpy).toHaveBeenCalled(); creds.restore(); }); @@ -251,15 +259,16 @@ describe("web session", () => { release = resolve; }); - const saveCreds = vi.fn(async () => { + const writeFileSpy = vi.spyOn(fsSync.promises, "writeFile").mockImplementation(async () => { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); await gate; inFlight -= 1; }); + vi.spyOn(fsSync.promises, "rename").mockResolvedValue(undefined); useMultiFileAuthStateMock.mockResolvedValueOnce({ state: { creds: {} as never, keys: {} as never }, - saveCreds, + saveCreds: vi.fn(), }); await createWaSocket(false, false); @@ -277,7 +286,7 @@ describe("web session", () => { await flushCredsUpdate(); await flushCredsUpdate(); - expect(saveCreds).toHaveBeenCalledTimes(2); + expect(writeFileSpy).toHaveBeenCalledTimes(2); expect(maxInFlight).toBe(1); expect(inFlight).toBe(0); }); @@ -294,24 +303,32 @@ describe("web session", () => { releaseB = resolve; }); - const saveCredsA = vi.fn(async () => { - inFlightA += 1; - await gateA; - inFlightA -= 1; - }); - const saveCredsB = vi.fn(async () => { - inFlightB += 1; - await gateB; - inFlightB -= 1; - }); + const writeFileSpy = vi + .spyOn(fsSync.promises, "writeFile") + .mockImplementation(async (...args) => { + const normalized = String(args[0]); + if (normalized.includes("/tmp/wa-a/")) { + inFlightA += 1; + await gateA; + inFlightA -= 1; + return; + } + if (normalized.includes("/tmp/wa-b/")) { + inFlightB += 1; + await gateB; + inFlightB -= 1; + return; + } + }); + vi.spyOn(fsSync.promises, "rename").mockResolvedValue(undefined); useMultiFileAuthStateMock .mockResolvedValueOnce({ state: { creds: {} as never, keys: {} as never }, - saveCreds: saveCredsA, + saveCreds: vi.fn(), }) .mockResolvedValueOnce({ state: { creds: {} as never, keys: {} as never }, - saveCreds: saveCredsB, + saveCreds: vi.fn(), }); await createWaSocket(false, false, { authDir: "/tmp/wa-a" }); @@ -324,8 +341,7 @@ describe("web session", () => { await flushCredsUpdate(); - expect(saveCredsA).toHaveBeenCalledTimes(1); - expect(saveCredsB).toHaveBeenCalledTimes(1); + expect(writeFileSpy).toHaveBeenCalledTimes(2); expect(inFlightA).toBe(1); expect(inFlightB).toBe(1); @@ -340,6 +356,8 @@ describe("web session", () => { it("rotates creds backup when creds.json is valid JSON", async () => { const creds = mockCredsJsonSpies("{}"); + const writeFileSpy = vi.spyOn(fsSync.promises, "writeFile").mockResolvedValue(undefined); + vi.spyOn(fsSync.promises, "rename").mockResolvedValue(undefined); const backupSuffix = path.join( "/tmp", "openclaw-oauth", @@ -349,14 +367,36 @@ describe("web session", () => { ); await createWaSocket(false, false); - const saveCreds = await emitCredsUpdateAndReadSaveCreds(); + await emitCredsUpdateAndReadSaveCreds(); expect(creds.copySpy).toHaveBeenCalledTimes(1); const args = creds.copySpy.mock.calls[0] ?? []; expect(String(args[0] ?? "")).toContain(creds.credsSuffix); expect(String(args[1] ?? "")).toContain(backupSuffix); - expect(saveCreds).toHaveBeenCalled(); + expect(writeFileSpy).toHaveBeenCalled(); creds.restore(); }); + + it("writes creds.json atomically via temp file and rename", async () => { + const writeFileSpy = vi.spyOn(fsSync.promises, "writeFile").mockResolvedValue(undefined); + const renameSpy = vi.spyOn(fsSync.promises, "rename").mockResolvedValue(undefined); + const rmSpy = vi.spyOn(fsSync.promises, "rm").mockResolvedValue(undefined); + + await writeCredsJsonAtomically( + "/tmp/openclaw-oauth/whatsapp/default", + { me: { id: "123@s.whatsapp.net" } }, + { warn: vi.fn() } as never, + ); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + expect(renameSpy).toHaveBeenCalledTimes(1); + expect(rmSpy).not.toHaveBeenCalled(); + const writePath = String(writeFileSpy.mock.calls[0]?.[0] ?? ""); + const renameArgs = renameSpy.mock.calls[0] ?? []; + expect(writePath).toContain(".creds."); + expect(String(renameArgs[1] ?? "")).toContain( + path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"), + ); + }); }); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 34d50414ab2..5f507dadec2 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; +import fs from "node:fs/promises"; import type { Agent } from "node:https"; +import path from "node:path"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { VERSION } from "openclaw/plugin-sdk/cli-runtime"; import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared"; @@ -16,6 +18,7 @@ import { } from "./auth-store.js"; import { formatError, getStatusCode } from "./session-errors.js"; import { + BufferJSON, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, @@ -41,6 +44,32 @@ async function loadQrTerminal() { return mod.default ?? mod; } +export async function writeCredsJsonAtomically( + authDir: string, + creds: unknown, + logger: ReturnType, +): Promise { + const credsPath = resolveWebCredsPath(authDir); + const tempPath = path.join(authDir, `.creds.${process.pid}.${Date.now()}.tmp`); + try { + await fs.writeFile(tempPath, JSON.stringify(creds, BufferJSON.replacer), { mode: 0o600 }); + await fs.rename(tempPath, credsPath); + try { + fsSync.chmodSync(credsPath, 0o600); + } catch { + // best-effort on platforms that support it + } + } catch (err) { + try { + await fs.rm(tempPath, { force: true }); + } catch { + // best-effort cleanup + } + logger.warn({ error: String(err) }, "failed atomic WhatsApp creds write"); + throw err; + } +} + // Per-authDir queues so multi-account creds saves don't block each other. const credsSaveQueues = new Map>(); const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; @@ -122,7 +151,10 @@ export async function createWaSocket( await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); maybeRestoreCredsFromBackup(authDir); - const { state, saveCreds } = await useMultiFileAuthState(authDir); + const { state } = await useMultiFileAuthState(authDir); + const saveCreds = async () => { + await writeCredsJsonAtomically(authDir, state.creds, sessionLogger); + }; const { version } = await fetchLatestBaileysVersion(); const agent = await resolveEnvProxyAgent(sessionLogger); const fetchAgent = await resolveEnvFetchDispatcher(sessionLogger, agent); diff --git a/test/mocks/baileys.ts b/test/mocks/baileys.ts index 85e7b45b06e..dfa293e9232 100644 --- a/test/mocks/baileys.ts +++ b/test/mocks/baileys.ts @@ -26,6 +26,10 @@ export type MockBaileysSocket = { }; export type MockBaileysModule = { + BufferJSON: { + replacer: (key: string, value: unknown) => unknown; + reviver: (key: string, value: unknown) => unknown; + }; DisconnectReason: { loggedOut: number }; extractMessageContent: ReturnType>; fetchLatestBaileysVersion: ReturnType>; @@ -148,6 +152,10 @@ export function createMockBaileys(): { }); const mod: MockBaileysModule = { + BufferJSON: { + replacer: (_key: string, value: unknown) => value, + reviver: (_key: string, value: unknown) => value, + }, DisconnectReason: { loggedOut: 401 }, extractMessageContent: vi.fn((message) => mockExtractMessageContent(message),