diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef32c766b5..019aa0cdd4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -440,6 +440,7 @@ Docs: https://docs.openclaw.ai - iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, so brief local transport stalls do not immediately bounce the channel. (#65393) Thanks @vincentkoc. - Status/session_status: move shared session status text into a neutral internal status module and keep the tool importing a local runtime shim, so built `session_status` no longer depends on reply command internals or a bundler-opaque runtime import. (#65807) Thanks @dutifulbob. - QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims. +- WhatsApp/web: rewrite queued `creds.json` updates atomically so interrupted saves do not leave truncated login state behind. (#63577) thanks @OwenYWT ## 2026.4.9 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..4821e413213 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import fsSync from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,17 +13,25 @@ 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)); } -async function emitCredsUpdateAndReadSaveCreds() { +async function emitCredsUpdate(authDir?: string) { const sock = getLastSocket(); - const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; sock.ev.emit("creds.update", {}); await flushCredsUpdate(); - return saveCreds; + if (authDir) { + await waitForCredsSaveQueue(authDir); + } +} + +function createTempAuthDir(prefix: string) { + return fsSync.mkdtempSync( + path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`), + ); } function mockCredsJsonSpies(readContents: string) { @@ -81,8 +90,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,7 +115,10 @@ describe("web session", () => { }); it("creates WA socket with QR handler", async () => { - await createWaSocket(true, false); + const authDir = createTempAuthDir("openclaw-wa-creds-test"); + const writeFileSpy = vi.spyOn(fs, "writeFile"); + + await createWaSocket(true, false, { authDir }); const makeWASocket = baileys.makeWASocket as ReturnType; expect(makeWASocket).toHaveBeenCalledWith( expect.objectContaining({ printQRInTerminal: false }), @@ -109,12 +127,14 @@ describe("web session", () => { const passedLogger = (passed as { logger?: { level?: string; trace?: unknown } }).logger; 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(); + await emitCredsUpdate(authDir); + + expect(writeFileSpy).toHaveBeenCalledWith( + expect.stringContaining(path.join(authDir, ".creds.")), + expect.any(String), + expect.objectContaining({ mode: 0o600 }), + ); + writeFileSpy.mockRestore(); }); it("uses ambient env proxy agent when HTTPS_PROXY is configured", async () => { @@ -233,14 +253,16 @@ describe("web session", () => { it("does not clobber creds backup when creds.json is corrupted", async () => { const creds = mockCredsJsonSpies("{"); + const writeFileSpy = vi.spyOn(fs, "writeFile"); await createWaSocket(false, false); - const saveCreds = await emitCredsUpdateAndReadSaveCreds(); + await emitCredsUpdate(); expect(creds.copySpy).not.toHaveBeenCalled(); - expect(saveCreds).toHaveBeenCalled(); + expect(writeFileSpy).toHaveBeenCalled(); creds.restore(); + writeFileSpy.mockRestore(); }); it("serializes creds.update saves to avoid overlapping writes", async () => { @@ -251,18 +273,22 @@ describe("web session", () => { release = resolve; }); - const saveCreds = vi.fn(async () => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await gate; - inFlight -= 1; - }); - useMultiFileAuthStateMock.mockResolvedValueOnce({ - state: { creds: {} as never, keys: {} as never }, - saveCreds, - }); + const authDir = createTempAuthDir("openclaw-wa-queue"); + const writeFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (file, data, options) => { + if (typeof file === "string" && file.startsWith(authDir) && file.includes(".creds.")) { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await gate; + inFlight -= 1; + return; + } + return writeFile(file, data, options as never); + }); - await createWaSocket(false, false); + await createWaSocket(false, false, { authDir }); const sock = getLastSocket(); sock.ev.emit("creds.update", {}); @@ -273,13 +299,12 @@ describe("web session", () => { (release as (() => void) | null)?.(); - // let both queued saves complete - await flushCredsUpdate(); - await flushCredsUpdate(); + await waitForCredsSaveQueue(authDir); - expect(saveCreds).toHaveBeenCalledTimes(2); + expect(writeFileSpy).toHaveBeenCalledTimes(2); expect(maxInFlight).toBe(1); expect(inFlight).toBe(0); + writeFileSpy.mockRestore(); }); it("lets different authDir queues flush independently", async () => { @@ -294,29 +319,30 @@ 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; - }); - useMultiFileAuthStateMock - .mockResolvedValueOnce({ - state: { creds: {} as never, keys: {} as never }, - saveCreds: saveCredsA, - }) - .mockResolvedValueOnce({ - state: { creds: {} as never, keys: {} as never }, - saveCreds: saveCredsB, + const authDirA = createTempAuthDir("openclaw-wa-a"); + const authDirB = createTempAuthDir("openclaw-wa-b"); + const writeFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (file, data, options) => { + if (typeof file === "string" && file.startsWith(authDirA) && file.includes(".creds.")) { + inFlightA += 1; + await gateA; + inFlightA -= 1; + return; + } + if (typeof file === "string" && file.startsWith(authDirB) && file.includes(".creds.")) { + inFlightB += 1; + await gateB; + inFlightB -= 1; + return; + } + return writeFile(file, data, options as never); }); - await createWaSocket(false, false, { authDir: "/tmp/wa-a" }); + await createWaSocket(false, false, { authDir: authDirA }); const sockA = getLastSocket(); - await createWaSocket(false, false, { authDir: "/tmp/wa-b" }); + await createWaSocket(false, false, { authDir: authDirB }); const sockB = getLastSocket(); sockA.ev.emit("creds.update", {}); @@ -324,22 +350,22 @@ 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); (releaseA as (() => void) | null)?.(); (releaseB as (() => void) | null)?.(); - await flushCredsUpdate(); - await flushCredsUpdate(); + await Promise.all([waitForCredsSaveQueue(authDirA), waitForCredsSaveQueue(authDirB)]); expect(inFlightA).toBe(0); expect(inFlightB).toBe(0); + writeFileSpy.mockRestore(); }); it("rotates creds backup when creds.json is valid JSON", async () => { const creds = mockCredsJsonSpies("{}"); + const writeFileSpy = vi.spyOn(fs, "writeFile"); const backupSuffix = path.join( "/tmp", "openclaw-oauth", @@ -349,14 +375,86 @@ describe("web session", () => { ); await createWaSocket(false, false); - const saveCreds = await emitCredsUpdateAndReadSaveCreds(); + await emitCredsUpdate(); 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(); + writeFileSpy.mockRestore(); + }); + + it("writes creds.json atomically via temp file and rename", async () => { + const writeFileSpy = vi.spyOn(fs, "writeFile").mockResolvedValue(undefined); + const renameSpy = vi.spyOn(fs, "rename").mockResolvedValue(undefined); + const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); + const chmodSpy = vi.spyOn(fsSync, "chmodSync").mockImplementation(() => {}); + + await writeCredsJsonAtomically("/tmp/openclaw-oauth/whatsapp/default", { + me: { id: "123@s.whatsapp.net" }, + }); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + expect(renameSpy).toHaveBeenCalledTimes(1); + expect(rmSpy).not.toHaveBeenCalled(); + expect(chmodSpy).not.toHaveBeenCalled(); + const writePath = writeFileSpy.mock.calls[0]?.[0]; + const renameArgs = renameSpy.mock.calls[0] ?? []; + expect(typeof writePath).toBe("string"); + expect(writePath).toContain(".creds."); + expect(String(renameArgs[1] ?? "")).toContain( + path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"), + ); + + writeFileSpy.mockRestore(); + renameSpy.mockRestore(); + rmSpy.mockRestore(); + chmodSpy.mockRestore(); + }); + + it("keeps the previous creds.json valid if the atomic rename fails", async () => { + const authDir = createTempAuthDir("openclaw-wa-creds-atomic"); + const credsPath = path.join(authDir, "creds.json"); + const originalCreds = { me: { id: "old@s.whatsapp.net" } }; + const nextCreds = { me: { id: "new@s.whatsapp.net" } }; + fsSync.writeFileSync(credsPath, JSON.stringify(originalCreds), "utf-8"); + const rename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + if ( + typeof from === "string" && + typeof to === "string" && + from.startsWith(path.join(authDir, ".creds.")) && + to === credsPath + ) { + throw new Error("simulated atomic rename failure"); + } + return rename(from, to); + }); + + useMultiFileAuthStateMock.mockResolvedValueOnce({ + state: { + creds: nextCreds as never, + keys: {} as never, + }, + saveCreds: vi.fn(), + }); + + await createWaSocket(false, false, { authDir }); + await emitCredsUpdate(authDir); + + const raw = fsSync.readFileSync(credsPath, "utf-8"); + const tempEntries = fsSync + .readdirSync(authDir) + .filter((entry) => entry.startsWith(".creds.") && entry.endsWith(".tmp")); + + expect(renameSpy).toHaveBeenCalledOnce(); + expect(() => JSON.parse(raw)).not.toThrow(); + expect(JSON.parse(raw)).toMatchObject(originalCreds); + expect(tempEntries).toHaveLength(0); + + renameSpy.mockRestore(); }); }); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 34d50414ab2..fd8ea3101b5 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,25 @@ async function loadQrTerminal() { return mod.default ?? mod; } +export async function writeCredsJsonAtomically( + authDir: string, + creds: unknown, +): 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); + } catch (err) { + try { + await fs.rm(tempPath, { force: true }); + } catch { + // best-effort cleanup + } + 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; @@ -92,11 +114,6 @@ async function safeSaveCreds( } try { await Promise.resolve(saveCreds()); - try { - fsSync.chmodSync(resolveWebCredsPath(authDir), 0o600); - } catch { - // best-effort on platforms that support it - } } catch (err) { logger.warn({ error: String(err) }, "failed saving WhatsApp creds"); } @@ -122,7 +139,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); + }; 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),