fix(whatsapp): write creds.json atomically (#63577)

Merged via squash.

Prepared head SHA: 70ef5b379f
Co-authored-by: OwenYWT <103073962+OwenYWT@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
OwenYWT 2026-04-16 13:44:46 +08:00 committed by GitHub
parent 4af7641350
commit b878d50e0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 189 additions and 61 deletions

View file

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

View file

@ -1,4 +1,5 @@
export {
BufferJSON,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,

View file

@ -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<void>((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<string, string>) {
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<typeof vi.fn>;
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();
});
});

View file

@ -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<void> {
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<string, Promise<void>>();
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);

View file

@ -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<typeof vi.fn<ExtractMessageContentFn>>;
fetchLatestBaileysVersion: ReturnType<typeof vi.fn<FetchLatestBaileysVersionFn>>;
@ -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<ExtractMessageContentFn>((message) =>
mockExtractMessageContent(message),