mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:40:43 +00:00
fix(whatsapp): write creds.json atomically
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
BufferJSON,
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
|
||||
@@ -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<void>((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<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,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<typeof vi.fn>;
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof getChildLogger>,
|
||||
): 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);
|
||||
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<string, Promise<void>>();
|
||||
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user