diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 6eef5659832e..8abae4c23dce 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -1,5 +1,8 @@ // Whatsapp tests cover connection controller plugin behavior. import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { DisconnectReason } from "baileys"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; @@ -8,8 +11,9 @@ import { waitForWhatsAppLoginResult, WhatsAppConnectionController, } from "./connection-controller.js"; +import { enqueueCredsSave, writeCredsJsonAtomically } from "./creds-persistence.js"; import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js"; -import { createWaSocket, waitForWaConnection } from "./session.js"; +import { createWaSocket, readWebAuthExistsForDecision, waitForWaConnection } from "./session.js"; import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js"; vi.mock("./session.js", async () => { @@ -18,11 +22,13 @@ vi.mock("./session.js", async () => { ...actual, createWaSocket: vi.fn(), waitForWaConnection: vi.fn(), + readWebAuthExistsForDecision: vi.fn(async () => ({ outcome: "stable" as const, exists: true })), }; }); const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); +const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision); function createListenerStub(messageId = "ok") { return { @@ -47,6 +53,9 @@ describe("WhatsAppConnectionController", () => { beforeEach(() => { vi.clearAllMocks(); + readWebAuthExistsForDecisionMock + .mockReset() + .mockResolvedValue({ outcome: "stable", exists: true }); controller = new WhatsAppConnectionController({ accountId: "work", authDir: "/tmp/wa-auth", @@ -249,6 +258,102 @@ describe("WhatsAppConnectionController", () => { expect(waitForConnection).toHaveBeenCalledTimes(2); }); + it("returns a retryable failure when the socket opens before auth persistence settles", async () => { + readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "unstable" }); + const waitForConnection = vi.fn().mockResolvedValueOnce(undefined); + + const result = await waitForWhatsAppLoginResult({ + sock: createSocketWithTransportEmitter() as never, + authDir: "/tmp/wa-auth", + isLegacyAuthDir: false, + verbose: false, + runtime: { log: vi.fn() } as never, + waitForConnection: waitForConnection as never, + }); + + expect(result.outcome).toBe("failed"); + if (result.outcome === "failed") { + expect(result.message).toMatch(/retry/i); + expect((result.error as { code?: string })?.code).toBe("whatsapp-auth-unstable"); + } + }); + + it("returns a retryable failure when auth is not linked on disk after the socket opens", async () => { + readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "stable", exists: false }); + const waitForConnection = vi.fn().mockResolvedValueOnce(undefined); + + const result = await waitForWhatsAppLoginResult({ + sock: createSocketWithTransportEmitter() as never, + authDir: "/tmp/wa-auth", + isLegacyAuthDir: false, + verbose: false, + runtime: { log: vi.fn() } as never, + waitForConnection: waitForConnection as never, + }); + + expect(result.outcome).toBe("failed"); + if (result.outcome === "failed") { + expect(result.message).toMatch(/retry/i); + expect((result.error as { code?: string })?.code).toBe("whatsapp-auth-unstable"); + } + }); + + it("returns connected only after auth is confirmed durable on disk", async () => { + readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "stable", exists: true }); + const waitForConnection = vi.fn().mockResolvedValueOnce(undefined); + const sock = createSocketWithTransportEmitter(); + + const result = await waitForWhatsAppLoginResult({ + sock: sock as never, + authDir: "/tmp/wa-auth", + isLegacyAuthDir: false, + verbose: false, + runtime: { log: vi.fn() } as never, + waitForConnection: waitForConnection as never, + }); + + expect(result).toEqual({ outcome: "connected", restarted: false, sock }); + expect(readWebAuthExistsForDecisionMock).toHaveBeenCalledWith("/tmp/wa-auth"); + }); + + it("waits for queued creds persistence so linked auth survives an auth-dir reuse", async () => { + const actualSession = await vi.importActual("./session.js"); + const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "wa-auth-durability-")); + try { + readWebAuthExistsForDecisionMock.mockImplementation( + actualSession.readWebAuthExistsForDecision, + ); + let credsSaved = false; + enqueueCredsSave( + authDir, + async () => { + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await writeCredsJsonAtomically(authDir, { me: { id: "123@s.whatsapp.net" } }); + credsSaved = true; + }, + () => {}, + ); + + const result = await waitForWhatsAppLoginResult({ + sock: createSocketWithTransportEmitter() as never, + authDir, + isLegacyAuthDir: false, + verbose: false, + runtime: { log: vi.fn() } as never, + waitForConnection: vi.fn().mockResolvedValueOnce(undefined) as never, + }); + + expect(credsSaved).toBe(true); + expect(result.outcome).toBe("connected"); + // A fresh read of the same auth dir is what a restarted/rebuilt container does. + await expect(actualSession.webAuthExists(authDir)).resolves.toBe(true); + } finally { + await fs.rm(authDir, { recursive: true, force: true }); + } + }); + it("keeps the previous registered controller until a replacement listener is ready", async () => { const liveController = new WhatsAppConnectionController({ accountId: "work", diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 84cec8bd6ff1..982142bc6edf 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -14,7 +14,9 @@ import { formatError, getStatusCode, logoutWeb, + readWebAuthExistsForDecision, waitForWaConnection, + WhatsAppAuthUnstableError, } from "./session.js"; import { DEFAULT_WHATSAPP_SOCKET_TIMING, @@ -30,6 +32,10 @@ const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE = "WhatsApp connection timed out before login; retrying with a fresh socket…"; const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE = "WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again."; +const WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE = + "WhatsApp connected, but saving the linked credentials has not settled on disk yet. Retry login in a moment."; +const WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE = + "WhatsApp connected, but the linked credentials were not found on disk. Retry login in a moment."; export const WHATSAPP_LOGGED_OUT_QR_MESSAGE = "WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR."; export const WHATSAPP_WATCHDOG_TIMEOUT_ERROR = "watchdog-timeout"; @@ -234,6 +240,22 @@ export async function waitForWhatsAppLoginResult(params: { while (true) { try { await wait(currentSock, { timeout: "none" }); + // Socket open only proves in-memory auth; require persisted creds before success. + const persistedAuth = await readWebAuthExistsForDecision(params.authDir); + if (persistedAuth.outcome === "unstable") { + return { + outcome: "failed", + message: WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE, + error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE), + }; + } + if (!persistedAuth.exists) { + return { + outcome: "failed", + message: WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE, + error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE), + }; + } return { outcome: "connected", restarted: postPairingRestarted || timeoutRestarted, diff --git a/extensions/whatsapp/src/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts index abab4b1644c4..8ff6a6f51cb0 100644 --- a/extensions/whatsapp/src/login-qr.test.ts +++ b/extensions/whatsapp/src/login-qr.test.ts @@ -114,6 +114,9 @@ describe("login-qr", () => { // Baileys v7 wraps the error: { error: BoomError(515) } .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) .mockResolvedValueOnce(undefined); + readWebAuthExistsForDecisionMock + .mockResolvedValueOnce({ outcome: "stable", exists: false }) + .mockResolvedValue({ outcome: "stable", exists: true }); const start = await startWebLoginWithQr({ timeoutMs: 5000, @@ -242,6 +245,34 @@ describe("login-qr", () => { expect(createWaSocketMock).not.toHaveBeenCalled(); }); + it("does not report linked success when the socket opens before creds persistence stabilizes", async () => { + const accountId = "socket-open-before-persistence"; + waitForWaConnectionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout(() => resolve(undefined), 20); + }), + ); + readWebAuthExistsForDecisionMock + .mockResolvedValueOnce({ outcome: "stable", exists: false }) + .mockResolvedValue({ outcome: "unstable" }); + + const start = await startWebLoginWithQr({ + timeoutMs: 5000, + accountId, + }); + expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data"); + + const result = await waitForWebLogin({ + timeoutMs: 5000, + currentQrDataUrl: start.qrDataUrl, + accountId, + }); + + expect(result.connected).toBe(false); + expect(result.message).toMatch(/retry/i); + }); + it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => { createWaSocketMock.mockImplementationOnce( async ( @@ -255,6 +286,9 @@ describe("login-qr", () => { ); waitForWaConnectionMock.mockResolvedValueOnce(undefined); readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null }); + readWebAuthExistsForDecisionMock + .mockResolvedValueOnce({ outcome: "stable", exists: false }) + .mockResolvedValue({ outcome: "stable", exists: true }); const result = await startWebLoginWithQr({ timeoutMs: 5000 }); @@ -310,6 +344,9 @@ describe("login-qr", () => { setTimeout(() => resolve(undefined), 20); }), ); + readWebAuthExistsForDecisionMock + .mockResolvedValueOnce({ outcome: "stable", exists: false }) + .mockResolvedValue({ outcome: "stable", exists: true }); const start = await startWebLoginWithQr({ timeoutMs: 5000, @@ -351,6 +388,9 @@ describe("login-qr", () => { resolveLogin = resolve; }), ); + readWebAuthExistsForDecisionMock + .mockResolvedValueOnce({ outcome: "stable", exists: false }) + .mockResolvedValue({ outcome: "stable", exists: true }); const start = await startWebLoginWithQr({ timeoutMs: 5000, diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index 2b0acb2fbb8b..7e61fc3e1e69 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -59,6 +59,10 @@ vi.mock("./session.js", async () => { waitForWaConnection: waitForWaConnectionLocal, formatError: formatErrorLocal, getStatusCode, + readWebAuthExistsForDecision: vi.fn(async () => ({ + outcome: "stable" as const, + exists: true, + })), logoutWeb: vi.fn(async (params: { authDir?: string }) => { await fs.rm(params.authDir ?? authDir, { recursive: true, diff --git a/extensions/whatsapp/src/login.test.ts b/extensions/whatsapp/src/login.test.ts index d05ec67efde3..546e782acfd9 100644 --- a/extensions/whatsapp/src/login.test.ts +++ b/extensions/whatsapp/src/login.test.ts @@ -17,6 +17,10 @@ vi.mock("./session.js", async () => { ...actual, createWaSocket: vi.fn().mockResolvedValue(sock), waitForWaConnection: vi.fn().mockResolvedValue(undefined), + readWebAuthExistsForDecision: vi.fn(async () => ({ + outcome: "stable" as const, + exists: true, + })), }; });