diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6c5fefe80..ffa420c03b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai - macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199) - CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras. - Control UI/settings: reset scroll position when switching settings pages and align details headers. (#68150) Thanks @BunsDev. +- WhatsApp/gateway: harden WhatsApp auth persistence and backup recovery, model unstable auth state explicitly in setup/status/health, recover backup-backed login without forcing a fresh QR, and keep local gateway handoff and channel restarts truthful after login. Thanks @mcaxtr. - OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc. - OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc. - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift index fd516480f96..27deb1db907 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift @@ -60,7 +60,7 @@ extension ChannelsStore { timeoutMs: 35000) self.whatsappLoginMessage = result.message self.whatsappLoginQrDataUrl = result.qrDataUrl - self.whatsappLoginConnected = nil + self.whatsappLoginConnected = result.connected shouldAutoWait = autoWait && result.qrDataUrl != nil } catch { self.whatsappLoginMessage = error.localizedDescription @@ -148,6 +148,7 @@ extension ChannelsStore { private struct WhatsAppLoginStartResult: Codable { let qrDataUrl: String? let message: String + let connected: Bool? } private struct WhatsAppLoginWaitResult: Codable { diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 1192548d91f..36d5587b468 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2481,6 +2481,24 @@ public struct ChannelsStatusResult: Codable, Sendable { } } +public struct ChannelsStartParams: Codable, Sendable { + public let channel: String + public let accountid: String? + + public init( + channel: String, + accountid: String?) + { + self.channel = channel + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case channel + case accountid = "accountId" + } +} + public struct ChannelsLogoutParams: Codable, Sendable { public let channel: String public let accountid: String? diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 1192548d91f..36d5587b468 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2481,6 +2481,24 @@ public struct ChannelsStatusResult: Codable, Sendable { } } +public struct ChannelsStartParams: Codable, Sendable { + public let channel: String + public let accountid: String? + + public init( + channel: String, + accountid: String?) + { + self.channel = channel + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case channel + case accountid = "accountId" + } +} + public struct ChannelsLogoutParams: Codable, Sendable { public let channel: String public let accountid: String? diff --git a/extensions/whatsapp/src/auth-store.test.ts b/extensions/whatsapp/src/auth-store.test.ts new file mode 100644 index 00000000000..42df0b6c5b4 --- /dev/null +++ b/extensions/whatsapp/src/auth-store.test.ts @@ -0,0 +1,203 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + logoutWeb, + pickWebChannel, + readWebAuthSnapshot, + readWebAuthState, + restoreCredsFromBackupIfNeeded, + webAuthExists, + WhatsAppAuthUnstableError, + WHATSAPP_AUTH_UNSTABLE_CODE, +} from "./auth-store.js"; +import type { CredsQueueWaitResult } from "./creds-persistence.js"; + +const hoisted = vi.hoisted(() => ({ + waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise>( + async () => "drained", + ), +})); + +vi.mock("./creds-persistence.js", async () => { + const actual = + await vi.importActual("./creds-persistence.js"); + return { + ...actual, + waitForCredsSaveQueueWithTimeout: hoisted.waitForCredsSaveQueueWithTimeout, + }; +}); + +function createTempAuthDir(prefix: string) { + return fsSync.mkdtempSync( + path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`), + ); +} + +describe("auth-store", () => { + beforeEach(() => { + hoisted.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue("drained"); + }); + + it("does not restore creds from backup on ordinary reads", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-read"); + const credsPath = path.join(authDir, "creds.json"); + const backupPath = path.join(authDir, "creds.json.bak"); + fsSync.writeFileSync(backupPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8"); + + await expect(webAuthExists(authDir)).resolves.toBe(false); + expect(fsSync.existsSync(credsPath)).toBe(false); + }); + + it("restores creds from a regular backup file", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-restore"); + const credsPath = path.join(authDir, "creds.json"); + fsSync.writeFileSync(credsPath, "{", "utf-8"); + fsSync.writeFileSync( + path.join(authDir, "creds.json.bak"), + JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), + "utf-8", + ); + + await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(true); + expect(JSON.parse(fsSync.readFileSync(credsPath, "utf-8"))).toEqual({ + me: { id: "123@s.whatsapp.net" }, + }); + }); + + it("refuses to restore creds from a symlinked backup path", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-restore-symlink"); + const targetPath = path.join(authDir, "backup-target.json"); + const backupPath = path.join(authDir, "creds.json.bak"); + const credsPath = path.join(authDir, "creds.json"); + fsSync.writeFileSync(targetPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8"); + fsSync.symlinkSync(targetPath, backupPath); + fsSync.writeFileSync(credsPath, "{", "utf-8"); + + await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false); + expect(fsSync.readFileSync(credsPath, "utf-8")).toBe("{"); + }); + + it("reports linked auth state and snapshot from the shared read helper", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-linked"); + fsSync.writeFileSync( + path.join(authDir, "creds.json"), + JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }), + "utf-8", + ); + + await expect(readWebAuthState(authDir)).resolves.toBe("linked"); + await expect(readWebAuthSnapshot(authDir)).resolves.toMatchObject({ + state: "linked", + authAgeMs: expect.any(Number), + selfId: expect.objectContaining({ e164: "+15551234567" }), + }); + }); + + it("reports unstable auth state when the shared barrier read times out", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state"); + fsSync.writeFileSync( + path.join(authDir, "creds.json"), + JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }), + "utf-8", + ); + hoisted.waitForCredsSaveQueueWithTimeout + .mockResolvedValueOnce("timed_out") + .mockResolvedValueOnce("timed_out"); + + await expect(readWebAuthState(authDir)).resolves.toBe("unstable"); + await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({ + state: "unstable", + authAgeMs: null, + selfId: { e164: null, jid: null, lid: null }, + }); + }); + + it("clears unreadable auth state on explicit logout", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-logout"); + fsSync.writeFileSync(path.join(authDir, "creds.json"), "{", "utf-8"); + fsSync.writeFileSync( + path.join(authDir, "creds.json.bak"), + JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), + "utf-8", + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true); + expect(fsSync.existsSync(authDir)).toBe(false); + }); + + it("does not delete the whole legacy auth root when targeted cleanup fails", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-legacy-failure"); + fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8"); + fsSync.writeFileSync(path.join(authDir, "oauth.json"), '{"token":true}', "utf-8"); + fsSync.writeFileSync(path.join(authDir, "session-abc.json"), "{}", "utf-8"); + const originalRm = fs.rm; + const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => { + if (String(target).endsWith("creds.json")) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + } + return await originalRm.call(fs, target, options as never); + }); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect( + logoutWeb({ authDir, isLegacyAuthDir: true, runtime: runtime as never }), + ).rejects.toThrow("EACCES"); + expect(fsSync.existsSync(authDir)).toBe(true); + expect(fsSync.existsSync(path.join(authDir, "oauth.json"))).toBe(true); + rmSpy.mockRestore(); + }); + + it("clears auth state even when directory enumeration fails", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-readdir"); + fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8"); + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true); + expect(fsSync.existsSync(authDir)).toBe(false); + readdirSpy.mockRestore(); + }); + + it("does not delete unrelated non-empty directories on logout", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-unrelated"); + fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false); + expect(fsSync.existsSync(authDir)).toBe(true); + expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true); + }); + + it("throws a typed unstable-auth error when channel selection times out", async () => { + hoisted.waitForCredsSaveQueueWithTimeout.mockResolvedValueOnce("timed_out"); + + await expect(pickWebChannel("auto", "/tmp/openclaw-wa-auth-unstable")).rejects.toEqual( + expect.objectContaining({ + code: WHATSAPP_AUTH_UNSTABLE_CODE, + name: WhatsAppAuthUnstableError.name, + }), + ); + }); +}); diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index 172306afa67..f4f111aa9e6 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -8,10 +9,29 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveOAuthDir } from "./auth-store.runtime.js"; import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js"; +import { + waitForCredsSaveQueueWithTimeout, + type CredsQueueWaitResult, +} from "./creds-persistence.js"; import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js"; import { resolveUserPath, type WebChannel } from "./text-runtime.js"; export { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath }; +export const WHATSAPP_AUTH_UNSTABLE_CODE = "whatsapp-auth-unstable"; + +const authStoreLogger = getChildLogger({ module: "web-auth-store" }); +const emptyWebSelfId = () => ({ e164: null, jid: null, lid: null }) as const; +export type WhatsAppWebAuthState = "linked" | "not-linked" | "unstable"; + +export class WhatsAppAuthUnstableError extends Error { + readonly code = WHATSAPP_AUTH_UNSTABLE_CODE; + + constructor(message = "WhatsApp auth state is still stabilizing; retry shortly.") { + super(message); + this.name = "WhatsAppAuthUnstableError"; + } +} + export function resolveDefaultWebAuthDir(): string { return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); } @@ -33,8 +53,26 @@ export function readCredsJsonRaw(filePath: string): string | null { } } -export function maybeRestoreCredsFromBackup(authDir: string): void { +async function waitForWebAuthBarrier( + authDir: string, + context: string, +): Promise { + const result = await waitForCredsSaveQueueWithTimeout(authDir); + if (result === "timed_out") { + authStoreLogger.warn( + { + authDir, + context, + }, + "timed out waiting for queued WhatsApp creds save before auth read", + ); + } + return result; +} + +export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise { const logger = getChildLogger({ module: "web-session" }); + let tempRestorePath: string | null = null; try { const credsPath = resolveWebCredsPath(authDir); const backupPath = resolveWebCredsBackupPath(authDir); @@ -42,31 +80,44 @@ export function maybeRestoreCredsFromBackup(authDir: string): void { if (raw) { // Validate that creds.json is parseable. JSON.parse(raw); - return; + return false; } const backupRaw = readCredsJsonRaw(backupPath); if (!backupRaw) { - return; + return false; + } + const backupStats = await fs.lstat(backupPath).catch(() => null); + if (!backupStats?.isFile()) { + return false; } // Ensure backup is parseable before restoring. JSON.parse(backupRaw); - fsSync.copyFileSync(backupPath, credsPath); - try { - fsSync.chmodSync(credsPath, 0o600); - } catch { - // best-effort on platforms that support it - } + tempRestorePath = path.join(authDir, `.creds.restore-${randomUUID()}.tmp`); + await fs.writeFile(tempRestorePath, backupRaw, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + await fs.rename(tempRestorePath, credsPath); + tempRestorePath = null; logger.warn({ credsPath }, "restored corrupted WhatsApp creds.json from backup"); + return true; } catch { // ignore + } finally { + if (tempRestorePath) { + await fs.rm(tempRestorePath, { force: true }).catch(() => { + // best-effort temp cleanup + }); + } } + return false; } export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) { const resolvedAuthDir = resolveUserPath(authDir); - maybeRestoreCredsFromBackup(resolvedAuthDir); const credsPath = resolveWebCredsPath(resolvedAuthDir); try { await fs.access(resolvedAuthDir); @@ -86,6 +137,89 @@ export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir() } } +function resolveWebAuthState(params: { + linked: boolean; + barrierResult: CredsQueueWaitResult; +}): WhatsAppWebAuthState { + if (params.barrierResult === "timed_out") { + return "unstable"; + } + return params.linked ? "linked" : "not-linked"; +} + +async function readWebAuthStateCore( + authDir: string, + context: string, +): Promise<{ authDir: string; linked: boolean; state: WhatsAppWebAuthState }> { + const resolvedAuthDir = resolveUserPath(authDir); + const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, context); + const linked = await webAuthExists(resolvedAuthDir); + return { + authDir: resolvedAuthDir, + linked, + state: resolveWebAuthState({ linked, barrierResult }), + }; +} + +export function formatWhatsAppWebAuthStatusState(state: WhatsAppWebAuthState): string { + switch (state) { + case "linked": + return "linked"; + case "not-linked": + return "not linked"; + case "unstable": + return "auth stabilizing"; + } + const exhaustive: never = state; + return exhaustive; +} + +export async function readWebAuthState( + authDir: string = resolveDefaultWebAuthDir(), +): Promise { + return (await readWebAuthStateCore(authDir, "readWebAuthState")).state; +} + +export async function readWebAuthSnapshot(authDir: string = resolveDefaultWebAuthDir()) { + const auth = await readWebAuthStateCore(authDir, "readWebAuthSnapshot"); + return { + state: auth.state, + authAgeMs: auth.state === "linked" ? getWebAuthAgeMs(auth.authDir) : null, + selfId: auth.state === "linked" ? readWebSelfId(auth.authDir) : emptyWebSelfId(), + } as const; +} + +export async function readWebAuthExistsBestEffort(authDir: string = resolveDefaultWebAuthDir()) { + const state = await readWebAuthState(authDir); + return { + exists: state === "linked", + timedOut: state === "unstable", + } as const; +} + +export async function readWebAuthExistsForDecision( + authDir: string = resolveDefaultWebAuthDir(), +): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> { + const state = await readWebAuthState(authDir); + if (state === "unstable") { + return { outcome: "unstable" }; + } + return { + outcome: "stable", + exists: state === "linked", + }; +} + +export async function readWebAuthSnapshotBestEffort(authDir: string = resolveDefaultWebAuthDir()) { + const snapshot = await readWebAuthSnapshot(authDir); + return { + linked: snapshot.state === "linked", + timedOut: snapshot.state === "unstable", + authAgeMs: snapshot.authAgeMs, + selfId: snapshot.selfId, + } as const; +} + async function clearLegacyBaileysAuthState(authDir: string) { const entries = await fs.readdir(authDir, { withFileTypes: true }); const shouldDelete = (name: string) => { @@ -113,6 +247,45 @@ async function clearLegacyBaileysAuthState(authDir: string) { ); } +async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): Promise { + try { + const stats = await fs.stat(authDir); + if (!stats.isDirectory()) { + return true; + } + if (isLegacyAuthDir) { + const entries = await fs.readdir(authDir, { withFileTypes: true }); + return entries.some((entry) => { + if (!entry.isFile()) { + return false; + } + if (entry.name === "oauth.json") { + return false; + } + if (entry.name === "creds.json" || entry.name === "creds.json.bak") { + return true; + } + return entry.name.endsWith(".json") + ? /^(app-state-sync|session|sender-key|pre-key)-/.test(entry.name) + : false; + }); + } + const credsStats = await fs.stat(resolveWebCredsPath(authDir)).catch(() => null); + if (credsStats?.isFile()) { + return true; + } + const backupStats = await fs.stat(resolveWebCredsBackupPath(authDir)).catch(() => null); + return backupStats?.isFile() === true; + } catch (error) { + const codeValue = + error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : undefined; + const code = typeof codeValue === "string" ? codeValue : ""; + return code !== "ENOENT"; + } +} + export async function logoutWeb(params: { authDir?: string; isLegacyAuthDir?: boolean; @@ -120,8 +293,13 @@ export async function logoutWeb(params: { }) { const runtime = params.runtime ?? defaultRuntime; const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir()); - const exists = await webAuthExists(resolvedAuthDir); - if (!exists) { + const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, "logoutWeb"); + if (barrierResult === "timed_out") { + runtime.log( + info("WhatsApp auth state is still stabilizing; clearing cached credentials anyway."), + ); + } + if (!(await shouldClearOnLogout(resolvedAuthDir, Boolean(params.isLegacyAuthDir)))) { runtime.log(info("No WhatsApp Web session found; nothing to delete.")); return false; } @@ -139,7 +317,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { try { const credsPath = resolveWebCredsPath(resolveUserPath(authDir)); if (!fsSync.existsSync(credsPath)) { - return { e164: null, jid: null, lid: null } as const; + return emptyWebSelfId(); } const raw = fsSync.readFileSync(credsPath, "utf-8"); const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined; @@ -156,7 +334,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { lid: identity.lid ?? null, } as const; } catch { - return { e164: null, jid: null, lid: null } as const; + return emptyWebSelfId(); } } @@ -165,7 +343,6 @@ export async function readWebSelfIdentity( fallback?: { id?: string | null; lid?: string | null } | null, ): Promise { const resolvedAuthDir = resolveUserPath(authDir); - maybeRestoreCredsFromBackup(resolvedAuthDir); try { const raw = await fs.readFile(resolveWebCredsPath(resolvedAuthDir), "utf-8"); const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined; @@ -187,6 +364,21 @@ export async function readWebSelfIdentity( } } +export async function readWebSelfIdentityForDecision( + authDir: string = resolveDefaultWebAuthDir(), + fallback?: { id?: string | null; lid?: string | null } | null, +): Promise<{ outcome: "stable"; identity: WhatsAppSelfIdentity } | { outcome: "unstable" }> { + const resolvedAuthDir = resolveUserPath(authDir); + const result = await waitForWebAuthBarrier(resolvedAuthDir, "readWebSelfIdentityForDecision"); + if (result === "timed_out") { + return { outcome: "unstable" }; + } + return { + outcome: "stable", + identity: await readWebSelfIdentity(resolvedAuthDir, fallback), + }; +} + /** * Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing. * Helpful for heartbeats/observability to spot stale credentials. @@ -223,8 +415,11 @@ export async function pickWebChannel( authDir: string = resolveDefaultWebAuthDir(), ): Promise { const choice: WebChannel = pref === "auto" ? "web" : pref; - const hasWeb = await webAuthExists(authDir); - if (!hasWeb) { + const auth = await readWebAuthExistsForDecision(authDir); + if (auth.outcome === "unstable") { + throw new WhatsAppAuthUnstableError(); + } + if (!auth.exists) { throw new Error( `No WhatsApp Web session found. Run \`${formatCliCommand("openclaw channels login --channel whatsapp --verbose")}\` to link.`, ); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index ee90c4fcf3d..ce97327b6bc 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -6,6 +6,7 @@ import { setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { withEnvAsync } from "openclaw/plugin-sdk/testing"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; +import { WhatsAppAuthUnstableError } from "./auth-store.js"; import { createWebInboundDeliverySpies, createMockWebListener, @@ -176,6 +177,59 @@ describe("web auto-reply connection", () => { expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring")); }); + it("retries inbox attach when auth state is still stabilizing", async () => { + const sleep = vi.fn(async () => {}); + const listenerFactory = vi.fn(async () => { + if (listenerFactory.mock.calls.length === 1) { + throw new WhatsAppAuthUnstableError( + "WhatsApp auth state is still stabilizing; retrying inbox attach.", + ); + } + return createMockWebListener(); + }); + const { runtime, controller, run } = startWebAutoReplyMonitor({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 3, factor: 1.1 }, + }); + + await vi.waitFor( + () => { + expect(listenerFactory).toHaveBeenCalledTimes(2); + }, + { timeout: 250, interval: 2 }, + ); + + controller.abort(); + await run; + + expect(sleep).toHaveBeenCalledWith(expect.any(Number), expect.any(AbortSignal)); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("inbox attach")); + }); + + it("stops retrying inbox attach when auth stays unstable past max attempts", async () => { + const sleep = vi.fn(async () => {}); + const listenerFactory = vi.fn(async () => { + throw new WhatsAppAuthUnstableError( + "WhatsApp auth state is still stabilizing; retrying inbox attach.", + ); + }); + const { runtime, run } = startWebAutoReplyMonitor({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 }, + }); + + await run; + + expect(listenerFactory).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Retry 1/2")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring")); + }); + it("forces reconnect when watchdog closes without onClose", async () => { vi.useFakeTimers(); try { @@ -508,7 +562,8 @@ describe("web auto-reply connection", () => { const sendComposing = vi.fn().mockResolvedValue(undefined); const sendMedia = vi.fn().mockResolvedValue(undefined); - const replyResolver = vi.fn().mockImplementation(async (_ctx, opts) => { + const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => { + void ctx; opts?.onTypingController?.(typingMock); return { text: "final reply" }; }); diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 718a5cae371..18ad1c925fc 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -15,6 +15,7 @@ import { type RuntimeEnv, } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js"; +import { WHATSAPP_AUTH_UNSTABLE_CODE, WhatsAppAuthUnstableError } from "../auth-store.js"; import { WhatsAppConnectionController, type ManagedWhatsAppListener, @@ -85,6 +86,16 @@ function resolveExplicitWhatsAppDebounceOverride(params: { return channel.debounceMs; } +function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError { + return ( + error instanceof WhatsAppAuthUnstableError || + (typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === WHATSAPP_AUTH_UNSTABLE_CODE) + ); +} + export async function monitorWebChannel( verbose: boolean, listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket, @@ -101,7 +112,6 @@ export async function monitorWebChannel( const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId }); const reconnectLogger = getChildLogger({ module: "web-reconnect", runId }); const statusController = createWebChannelStatusController(tuning.statusSink); - const _status = statusController.snapshot(); statusController.emit(); const baseCfg = loadConfig(); @@ -215,89 +225,138 @@ export async function monitorWebChannel( return !hasControlCommand(msg.body, cfg); }; - const connection = await controller.openConnection({ - connectionId, - createListener: async ({ sock, connection }) => { - const onMessage = createWebOnMessageHandler({ - cfg, - verbose, - connectionId, - maxMediaBytes, - groupHistoryLimit, - groupHistories, - groupMemberNames, - echoTracker, - backgroundTasks: connection.backgroundTasks, - replyResolver: activeReplyResolver, - replyLogger, - baseMentionConfig, - account, - }); + let connection; + try { + connection = await controller.openConnection({ + connectionId, + createListener: async ({ sock, connection }) => { + const onMessage = createWebOnMessageHandler({ + cfg, + verbose, + connectionId, + maxMediaBytes, + groupHistoryLimit, + groupHistories, + groupMemberNames, + echoTracker, + backgroundTasks: connection.backgroundTasks, + replyResolver: activeReplyResolver, + replyLogger, + baseMentionConfig, + account, + }); - return (await (listenerFactory ?? attachWebInboxToSocket)({ - verbose, - accountId: account.accountId, - authDir: account.authDir, - mediaMaxMb: account.mediaMaxMb, - selfChatMode: account.selfChatMode, - sendReadReceipts: account.sendReadReceipts, - debounceMs: inboundDebounceMs, - shouldDebounce, - socketRef: controller.socketRef, - shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(), - disconnectRetryPolicy: reconnectPolicy, - disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(), - onMessage: async (msg: WebInboundMsg) => { - const inboundAt = Date.now(); - controller.noteInbound(inboundAt); - statusController.noteInbound(inboundAt); - await onMessage(msg); - }, - sock, - })) as ManagedWhatsAppListener; - }, - onHeartbeat: (snapshot) => { - const authAgeMs = getWebAuthAgeMs(account.authDir); - const minutesSinceLastMessage = snapshot.lastInboundAt - ? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000) - : null; + return (await (listenerFactory ?? attachWebInboxToSocket)({ + verbose, + accountId: account.accountId, + authDir: account.authDir, + mediaMaxMb: account.mediaMaxMb, + selfChatMode: account.selfChatMode, + sendReadReceipts: account.sendReadReceipts, + debounceMs: inboundDebounceMs, + shouldDebounce, + socketRef: controller.socketRef, + shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(), + disconnectRetryPolicy: reconnectPolicy, + disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(), + onMessage: async (msg: WebInboundMsg) => { + const inboundAt = Date.now(); + controller.noteInbound(inboundAt); + statusController.noteInbound(inboundAt); + await onMessage(msg); + }, + sock, + })) as ManagedWhatsAppListener; + }, + onHeartbeat: (snapshot) => { + const authAgeMs = getWebAuthAgeMs(account.authDir); + const minutesSinceLastMessage = snapshot.lastInboundAt + ? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000) + : null; - const logData = { - connectionId: snapshot.connectionId, - reconnectAttempts: snapshot.reconnectAttempts, - messagesHandled: snapshot.handledMessages, - lastInboundAt: snapshot.lastInboundAt, - authAgeMs, - uptimeMs: snapshot.uptimeMs, - ...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30 - ? { minutesSinceLastMessage } - : {}), - }; - - if (minutesSinceLastMessage && minutesSinceLastMessage > 30) { - heartbeatLogger.warn(logData, "⚠️ web gateway heartbeat - no messages in 30+ minutes"); - } else { - heartbeatLogger.info(logData, "web gateway heartbeat"); - } - }, - onWatchdogTimeout: (snapshot) => { - const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt; - const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000); - statusController.noteWatchdogStale(); - heartbeatLogger.warn( - { + const logData = { connectionId: snapshot.connectionId, - minutesSinceLastMessage, - lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null, + reconnectAttempts: snapshot.reconnectAttempts, messagesHandled: snapshot.handledMessages, + lastInboundAt: snapshot.lastInboundAt, + authAgeMs, + uptimeMs: snapshot.uptimeMs, + ...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30 + ? { minutesSinceLastMessage } + : {}), + }; + + if (minutesSinceLastMessage && minutesSinceLastMessage > 30) { + heartbeatLogger.warn( + logData, + "⚠️ web gateway heartbeat - no messages in 30+ minutes", + ); + } else { + heartbeatLogger.info(logData, "web gateway heartbeat"); + } + }, + onWatchdogTimeout: (snapshot) => { + const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt; + const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000); + statusController.noteWatchdogStale(); + heartbeatLogger.warn( + { + connectionId: snapshot.connectionId, + minutesSinceLastMessage, + lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null, + messagesHandled: snapshot.handledMessages, + }, + "Message timeout detected - forcing reconnect", + ); + whatsappHeartbeatLog.warn( + `No messages received in ${minutesSinceLastMessage}m - restarting connection`, + ); + }, + }); + } catch (error) { + if (!isRetryableAuthUnstableError(error)) { + throw error; + } + const retryDecision = controller.consumeReconnectAttempt(); + statusController.noteReconnectAttempts(retryDecision.reconnectAttempts); + statusController.noteClose({ + error: error.message, + reconnectAttempts: retryDecision.reconnectAttempts, + healthState: retryDecision.healthState, + }); + if (retryDecision.action === "stop") { + reconnectLogger.warn( + { + connectionId, + reconnectAttempts: retryDecision.reconnectAttempts, + maxAttempts: reconnectPolicy.maxAttempts, }, - "Message timeout detected - forcing reconnect", + "web reconnect: auth state stayed unstable; max attempts reached", ); - whatsappHeartbeatLog.warn( - `No messages received in ${minutesSinceLastMessage}m - restarting connection`, + runtime.error( + `WhatsApp auth state is still stabilizing after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Stopping web monitoring.`, ); - }, - }); + await controller.shutdown(); + break; + } + reconnectLogger.info( + { + connectionId, + reconnectAttempts: retryDecision.reconnectAttempts, + delayMs: retryDecision.delayMs, + }, + "web reconnect: auth state still stabilizing during inbox attach; retrying", + ); + runtime.error( + `WhatsApp auth state is still stabilizing. Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} for inbox attach in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`, + ); + try { + await controller.waitBeforeRetry(retryDecision.delayMs ?? 0); + } catch { + break; + } + continue; + } statusController.noteConnected(); controller.setUnhandledRejectionCleanup( diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index 88a5ebeebe6..13978d8c652 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -7,6 +7,11 @@ import { getWebAuthAgeMs as getWebAuthAgeMsImpl, logWebSelfId as logWebSelfIdImpl, logoutWeb as logoutWebImpl, + readWebAuthSnapshot as readWebAuthSnapshotImpl, + readWebAuthState as readWebAuthStateImpl, + readWebAuthExistsBestEffort as readWebAuthExistsBestEffortImpl, + readWebAuthExistsForDecision as readWebAuthExistsForDecisionImpl, + readWebAuthSnapshotBestEffort as readWebAuthSnapshotBestEffortImpl, readWebSelfId as readWebSelfIdImpl, webAuthExists as webAuthExistsImpl, } from "./auth-store.js"; @@ -18,6 +23,11 @@ type GetActiveWebListener = typeof import("./active-listener.js").getActiveWebLi type GetWebAuthAgeMs = typeof import("./auth-store.js").getWebAuthAgeMs; type LogWebSelfId = typeof import("./auth-store.js").logWebSelfId; type LogoutWeb = typeof import("./auth-store.js").logoutWeb; +type ReadWebAuthSnapshot = typeof import("./auth-store.js").readWebAuthSnapshot; +type ReadWebAuthState = typeof import("./auth-store.js").readWebAuthState; +type ReadWebAuthExistsBestEffort = typeof import("./auth-store.js").readWebAuthExistsBestEffort; +type ReadWebAuthExistsForDecision = typeof import("./auth-store.js").readWebAuthExistsForDecision; +type ReadWebAuthSnapshotBestEffort = typeof import("./auth-store.js").readWebAuthSnapshotBestEffort; type ReadWebSelfId = typeof import("./auth-store.js").readWebSelfId; type WebAuthExists = typeof import("./auth-store.js").webAuthExists; type LoginWeb = typeof import("./login.js").loginWeb; @@ -44,6 +54,36 @@ export function logoutWeb(...args: Parameters): ReturnType return logoutWebImpl(...args); } +export function readWebAuthSnapshot( + ...args: Parameters +): ReturnType { + return readWebAuthSnapshotImpl(...args); +} + +export function readWebAuthState( + ...args: Parameters +): ReturnType { + return readWebAuthStateImpl(...args); +} + +export function readWebAuthExistsBestEffort( + ...args: Parameters +): ReturnType { + return readWebAuthExistsBestEffortImpl(...args); +} + +export function readWebAuthExistsForDecision( + ...args: Parameters +): ReturnType { + return readWebAuthExistsForDecisionImpl(...args); +} + +export function readWebAuthSnapshotBestEffort( + ...args: Parameters +): ReturnType { + return readWebAuthSnapshotBestEffortImpl(...args); +} + export function readWebSelfId(...args: Parameters): ReturnType { return readWebSelfIdImpl(...args); } diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index cbf91219c1c..a7bcb9ee18e 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -3,6 +3,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createQueuedWizardPrompter } from "../../../test/helpers/plugins/setup-wizard.js"; import { whatsappApprovalAuth } from "./approval-auth.js"; +import { WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js"; import { whatsappPlugin } from "./channel.js"; import { checkWhatsAppHeartbeatReady } from "./heartbeat.js"; import type { OpenClawConfig } from "./runtime-api.js"; @@ -26,6 +27,13 @@ import { const hoisted = vi.hoisted(() => ({ loginWeb: vi.fn(async () => {}), pathExists: vi.fn(async () => false), + readWebAuthState: vi.fn(async (): Promise<"linked" | "not-linked" | "unstable"> => "not-linked"), + readWebAuthExistsForDecision: vi.fn( + async (): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> => ({ + outcome: "stable", + exists: false, + }), + ), resolveWhatsAppAuthDir: vi.fn(() => ({ authDir: "/tmp/openclaw-whatsapp-test", })), @@ -82,6 +90,15 @@ vi.mock("./accounts.js", async () => { }; }); +vi.mock("./auth-store.js", async () => { + const actual = await vi.importActual("./auth-store.js"); + return { + ...actual, + readWebAuthState: hoisted.readWebAuthState, + readWebAuthExistsForDecision: hoisted.readWebAuthExistsForDecision, + }; +}); + function createRuntime(): RuntimeEnv { return { error: vi.fn(), @@ -132,6 +149,13 @@ describe("whatsapp setup wizard", () => { hoisted.loginWeb.mockReset(); hoisted.pathExists.mockReset(); hoisted.pathExists.mockResolvedValue(false); + hoisted.readWebAuthState.mockReset(); + hoisted.readWebAuthState.mockResolvedValue("not-linked"); + hoisted.readWebAuthExistsForDecision.mockReset(); + hoisted.readWebAuthExistsForDecision.mockResolvedValue({ + outcome: "stable", + exists: false, + }); hoisted.resolveWhatsAppAuthDir.mockReset(); hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" }); }); @@ -397,11 +421,49 @@ describe("whatsapp setup wizard", () => { }, } as OpenClawConfig, deps: { - webAuthExists: async () => true, + readWebAuthExistsForDecision: async () => ({ + outcome: "stable" as const, + exists: true, + }), hasActiveWebListener: (accountId?: string) => accountId === "work", }, }); expect(result).toEqual({ ok: true, reason: "ok" }); }); + + it("heartbeat readiness returns unstable when auth state timing is unresolved", async () => { + const result = await checkWhatsAppHeartbeatReady({ + cfg: { + channels: { + whatsapp: { + accounts: { + default: { + authDir: "/tmp/default", + }, + }, + }, + }, + } as OpenClawConfig, + deps: { + readWebAuthExistsForDecision: async () => ({ outcome: "unstable" as const }), + hasActiveWebListener: () => true, + }, + }); + + expect(result).toEqual({ ok: false, reason: WHATSAPP_AUTH_UNSTABLE_CODE }); + }); + + it("does not treat unstable auth as configured in generic plugin config checks", async () => { + hoisted.readWebAuthState.mockResolvedValueOnce("unstable"); + + await expect( + whatsappPlugin.config.isConfigured?.( + { + authDir: "/tmp/work", + } as never, + {} as never, + ), + ).resolves.toBe(false); + }); }); diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 5f31952e02c..07adefa0326 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,6 +1,6 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; -import { webAuthExists } from "./auth-store.js"; +import { readWebAuthState } from "./auth-store.js"; import { resolveWhatsAppGroupIntroHint } from "./group-intro.js"; import { resolveWhatsAppGroupRequireMention, @@ -19,7 +19,7 @@ export const whatsappSetupPlugin: ChannelPlugin = { }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, - isConfigured: async (account) => await webAuthExists(account.authDir), + isConfigured: async (account) => (await readWebAuthState(account.authDir)) === "linked", }), lifecycle: { detectLegacyStateMigrations: ({ oauthDir }) => diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 664ed246180..9e0af3b535e 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -75,8 +75,10 @@ export const whatsappPlugin: ChannelPlugin = }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, - isConfigured: async (account) => - await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir), + isConfigured: async (account) => { + const channelRuntime = await loadWhatsAppChannelRuntime(); + return (await channelRuntime.readWebAuthState(account.authDir)) === "linked"; + }, }), agentTools: () => [createWhatsAppLoginTool()], allowlist: buildDmGroupAccountAllowlistAdapter({ @@ -182,24 +184,47 @@ export const whatsappPlugin: ChannelPlugin = }), collectStatusIssues: collectWhatsAppStatusIssues, buildChannelSummary: async ({ account, snapshot }) => { + const channelRuntime = await loadWhatsAppChannelRuntime(); const authDir = account.authDir; + const auth = authDir + ? await channelRuntime.readWebAuthSnapshot(authDir) + : { + state: "not-linked" as const, + authAgeMs: null, + selfId: { e164: null, jid: null, lid: null }, + }; const linked = typeof snapshot.linked === "boolean" ? snapshot.linked - : authDir - ? await (await loadWhatsAppChannelRuntime()).webAuthExists(authDir) - : false; - const authAgeMs = - linked && authDir - ? (await loadWhatsAppChannelRuntime()).getWebAuthAgeMs(authDir) - : null; + : auth.state === "unstable" + ? undefined + : auth.state === "linked"; + const summaryAuthState = + auth.state === "unstable" + ? auth.state + : linked === true + ? "linked" + : linked === false + ? "not-linked" + : undefined; + const statusState = summaryAuthState === undefined ? undefined : summaryAuthState; + const configured = + auth.state === "unstable" + ? typeof snapshot.configured === "boolean" + ? snapshot.configured + : true + : typeof linked === "boolean" + ? linked + : auth.state === "linked"; + const authAgeMs = typeof linked === "boolean" && linked ? auth.authAgeMs : null; const self = - linked && authDir - ? (await loadWhatsAppChannelRuntime()).readWebSelfId(authDir) - : { e164: null, jid: null }; + typeof linked === "boolean" && linked + ? auth.selfId + : { e164: null, jid: null, lid: null }; return { - configured: linked, - linked, + configured, + ...(statusState ? { statusState } : {}), + ...(typeof linked === "boolean" ? { linked } : {}), authAgeMs, self, running: snapshot.running ?? false, @@ -215,14 +240,20 @@ export const whatsappPlugin: ChannelPlugin = }; }, resolveAccountSnapshot: async ({ account, runtime }) => { - const linked = await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir); + const channelRuntime = await loadWhatsAppChannelRuntime(); + const authState = await channelRuntime.readWebAuthState(account.authDir); return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured: true, extra: { - linked, + statusState: authState, + ...(authState === "linked" + ? { linked: true } + : authState === "not-linked" + ? { linked: false } + : {}), connected: runtime?.connected ?? false, reconnectAttempts: runtime?.reconnectAttempts, lastConnectedAt: runtime?.lastConnectedAt ?? null, diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index d767dfecdcc..cf53f42e989 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -1,24 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; import { WhatsAppConnectionController } from "./connection-controller.js"; -import { - createWaSocket, - waitForCredsSaveQueueWithTimeout, - waitForWaConnection, -} from "./session.js"; +import { createWaSocket, waitForWaConnection } from "./session.js"; vi.mock("./session.js", async () => { const actual = await vi.importActual("./session.js"); return { ...actual, createWaSocket: vi.fn(), - waitForCredsSaveQueueWithTimeout: vi.fn(async () => {}), waitForWaConnection: vi.fn(), }; }); const createWaSocketMock = vi.mocked(createWaSocket); -const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); function createListenerStub(messageId = "ok") { @@ -81,24 +75,22 @@ describe("WhatsAppConnectionController", () => { expect(controller.getActiveListener()).toBeNull(); }); - it("flushes pending creds saves before opening a socket", async () => { + it("lets createWaSocket own the auth barrier before opening a socket", async () => { const callOrder: string[] = []; - waitForCredsSaveQueueWithTimeoutMock.mockImplementationOnce(async () => { - callOrder.push("wait"); - }); createWaSocketMock.mockImplementationOnce(async () => { callOrder.push("create"); return { ws: { close: vi.fn() } } as never; }); - waitForWaConnectionMock.mockResolvedValueOnce(undefined); + waitForWaConnectionMock.mockImplementationOnce(async () => { + callOrder.push("wait-for-connection"); + }); await controller.openConnection({ connectionId: "conn-flush-first", createListener: async () => createListenerStub() as never, }); - expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith("/tmp/wa-auth"); - expect(callOrder).toEqual(["wait", "create"]); + expect(callOrder).toEqual(["create", "wait-for-connection"]); }); it("keeps the previous registered controller until a replacement listener is ready", async () => { diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 4545cd797af..86523565c38 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -12,7 +12,6 @@ import { formatError, getStatusCode, logoutWeb, - waitForCredsSaveQueueWithTimeout, waitForWaConnection, } from "./session.js"; @@ -73,6 +72,13 @@ export type WhatsAppConnectionCloseDecision = { normalized: NormalizedConnectionCloseReason; }; +export type WhatsAppReconnectAttemptDecision = { + action: "stop" | "retry"; + delayMs?: number; + reconnectAttempts: number; + healthState: "stopped" | "reconnecting"; +}; + function createNeverResolvePromise(): Promise { return new Promise(() => {}); } @@ -175,7 +181,6 @@ export async function waitForWhatsAppLoginResult(params: { restarted = true; params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE)); closeWaSocket(currentSock); - await waitForCredsSaveQueueWithTimeout(params.authDir); try { currentSock = await createSocket(false, params.verbose, { authDir: params.authDir, @@ -347,7 +352,6 @@ export class WhatsAppConnectionController { let sock: WaSocket | null = null; let connection: WhatsAppLiveConnection | null = null; try { - await waitForCredsSaveQueueWithTimeout(this.authDir); sock = await createWaSocket(false, this.verbose, { authDir: this.authDir, }); @@ -449,6 +453,26 @@ export class WhatsAppConnectionController { }; } + const retryDecision = this.consumeReconnectAttempt(); + if (retryDecision.action === "stop") { + return { + action: "stop", + reconnectAttempts: retryDecision.reconnectAttempts, + healthState: retryDecision.healthState, + normalized, + }; + } + + return { + action: "retry", + delayMs: retryDecision.delayMs, + reconnectAttempts: retryDecision.reconnectAttempts, + healthState: retryDecision.healthState, + normalized, + }; + } + + consumeReconnectAttempt(): WhatsAppReconnectAttemptDecision { this.reconnectAttempts += 1; if ( this.reconnectPolicy.maxAttempts > 0 && @@ -458,7 +482,6 @@ export class WhatsAppConnectionController { action: "stop", reconnectAttempts: this.reconnectAttempts, healthState: "stopped", - normalized, }; } @@ -467,7 +490,6 @@ export class WhatsAppConnectionController { delayMs: computeBackoff(this.reconnectPolicy, this.reconnectAttempts), reconnectAttempts: this.reconnectAttempts, healthState: "reconnecting", - normalized, }; } diff --git a/extensions/whatsapp/src/creds-persistence.ts b/extensions/whatsapp/src/creds-persistence.ts new file mode 100644 index 00000000000..6c23b3e6985 --- /dev/null +++ b/extensions/whatsapp/src/creds-persistence.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveWebCredsPath } from "./creds-files.js"; +import { BufferJSON } from "./session.runtime.js"; + +const CREDS_FILE_MODE = 0o600; +const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; + +const credsSaveQueues = new Map>(); + +export type CredsQueueWaitResult = "drained" | "timed_out"; + +async function syncDirectory(dirPath: string): Promise { + let handle: Awaited> | undefined; + try { + handle = await fs.open(dirPath, "r"); + await handle.sync(); + } catch { + // best-effort on platforms that do not support directory fsync + } finally { + await handle?.close().catch(() => { + // best-effort close + }); + } +} + +export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise { + const credsPath = resolveWebCredsPath(authDir); + const tempPath = path.join(authDir, `.creds.${process.pid}.${randomUUID()}.tmp`); + const json = JSON.stringify(creds, BufferJSON.replacer); + + let handle: Awaited> | undefined; + try { + handle = await fs.open(tempPath, "w", CREDS_FILE_MODE); + await handle.writeFile(json, { encoding: "utf-8" }); + await handle.sync(); + await handle.close(); + handle = undefined; + + await fs.rename(tempPath, credsPath); + await fs.chmod(credsPath, CREDS_FILE_MODE).catch(() => { + // best-effort on platforms that support it + }); + await syncDirectory(path.dirname(credsPath)); + } catch (error) { + await handle?.close().catch(() => { + // best-effort close + }); + await fs.rm(tempPath, { force: true }).catch(() => { + // best-effort cleanup + }); + throw error; + } +} + +export function enqueueCredsSave( + authDir: string, + saveCreds: () => Promise | void, + onError: (error: unknown) => void, +): void { + const previous = credsSaveQueues.get(authDir) ?? Promise.resolve(); + const next = previous + .then(() => saveCreds()) + .catch((error) => { + onError(error); + }) + .finally(() => { + if (credsSaveQueues.get(authDir) === next) { + credsSaveQueues.delete(authDir); + } + }); + credsSaveQueues.set(authDir, next); +} + +export function waitForCredsSaveQueue(authDir?: string): Promise { + if (authDir) { + return credsSaveQueues.get(authDir) ?? Promise.resolve(); + } + return Promise.all(credsSaveQueues.values()).then(() => {}); +} + +export async function waitForCredsSaveQueueWithTimeout( + authDir: string, + timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS, +): Promise { + let flushTimeout: ReturnType | undefined; + return await Promise.race([ + waitForCredsSaveQueue(authDir).then(() => "drained" as const), + new Promise((resolve) => { + flushTimeout = setTimeout(() => resolve("timed_out"), timeoutMs); + }), + ]).finally(() => { + if (flushTimeout) { + clearTimeout(flushTimeout); + } + }); +} diff --git a/extensions/whatsapp/src/heartbeat.ts b/extensions/whatsapp/src/heartbeat.ts index bbe2ec73143..38745e1f840 100644 --- a/extensions/whatsapp/src/heartbeat.ts +++ b/extensions/whatsapp/src/heartbeat.ts @@ -1,4 +1,5 @@ import { resolveWhatsAppAccount } from "./accounts.js"; +import { readWebAuthExistsForDecision, WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { loadWhatsAppChannelRuntime } from "./shared.js"; @@ -6,7 +7,7 @@ export async function checkWhatsAppHeartbeatReady(params: { cfg: OpenClawConfig; accountId?: string; deps?: { - webAuthExists?: (authDir: string) => Promise; + readWebAuthExistsForDecision?: typeof readWebAuthExistsForDecision; hasActiveWebListener?: (accountId?: string) => boolean; }; }) { @@ -14,10 +15,13 @@ export async function checkWhatsAppHeartbeatReady(params: { return { ok: false as const, reason: "whatsapp-disabled" as const }; } const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - const authExists = await ( - params.deps?.webAuthExists ?? (await loadWhatsAppChannelRuntime()).webAuthExists + const authState = await ( + params.deps?.readWebAuthExistsForDecision ?? readWebAuthExistsForDecision )(account.authDir); - if (!authExists) { + if (authState.outcome === "unstable") { + return { ok: false as const, reason: WHATSAPP_AUTH_UNSTABLE_CODE }; + } + if (!authState.exists) { return { ok: false as const, reason: "whatsapp-not-linked" as const }; } const listenerActive = params.deps?.hasActiveWebListener diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 45aca63325e..8eec772a760 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -4,7 +4,7 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; -import { readWebSelfIdentity } from "../auth-store.js"; +import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../auth-store.js"; import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js"; import { DEFAULT_RECONNECT_POLICY, computeBackoff, sleepWithAbort } from "../reconnect.js"; import { createWaSocket, formatError, getStatusCode, waitForWaConnection } from "../session.js"; @@ -131,10 +131,16 @@ export async function attachWebInboxToSocket( ); } - const self = await readWebSelfIdentity( + const selfIdentity = await readWebSelfIdentityForDecision( options.authDir, sock.user as { id?: string | null; lid?: string | null } | undefined, ); + if (selfIdentity.outcome === "unstable") { + throw new WhatsAppAuthUnstableError( + "WhatsApp auth state is still stabilizing; retrying inbox attach.", + ); + } + const self = selfIdentity.identity; type QueuedInboundMessage = WebInboundMessage & { dedupeKey?: string; }; diff --git a/extensions/whatsapp/src/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts index b8d9298ed63..5020e23b89a 100644 --- a/extensions/whatsapp/src/login-qr.test.ts +++ b/extensions/whatsapp/src/login-qr.test.ts @@ -3,19 +3,25 @@ import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; import { createWaSocket, logoutWeb, - waitForCredsSaveQueueWithTimeout, + readWebAuthExistsForDecision, + readWebSelfId, + WHATSAPP_AUTH_UNSTABLE_CODE, waitForWaConnection, } from "./session.js"; vi.mock("./session.js", async () => { const actual = await vi.importActual("./session.js"); const createWaSocket = vi.fn( - async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => { + async ( + _printQr: boolean, + _verbose: boolean, + opts?: { authDir?: string; onQr?: (qr: string) => void }, + ) => { const sock = { ws: { close: vi.fn() } }; if (opts?.onQr) { setImmediate(() => opts.onQr?.("qr-data")); } - return sock; + return sock as never; }, ); const waitForWaConnection = vi.fn(); @@ -26,20 +32,21 @@ vi.mock("./session.js", async () => { (err as { status?: number })?.status ?? (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode, ); - const webAuthExists = vi.fn(async () => false); - const readWebSelfId = vi.fn(() => ({ e164: null, jid: null })); + const readWebAuthExistsForDecision = vi.fn(async () => ({ + outcome: "stable" as const, + exists: false, + })); + const readWebSelfId = vi.fn(() => ({ e164: null, jid: null, lid: null })); const logoutWeb = vi.fn(async () => true); - const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {}); return { ...actual, createWaSocket, waitForWaConnection, formatError, getStatusCode, - webAuthExists, + readWebAuthExistsForDecision, readWebSelfId, logoutWeb, - waitForCredsSaveQueueWithTimeout, }; }); @@ -48,8 +55,9 @@ vi.mock("./qr-image.js", () => ({ })); const createWaSocketMock = vi.mocked(createWaSocket); +const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision); +const readWebSelfIdMock = vi.mocked(readWebSelfId); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); -const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout); const logoutWebMock = vi.mocked(logoutWeb); async function flushTasks() { @@ -60,18 +68,35 @@ async function flushTasks() { describe("login-qr", () => { beforeEach(() => { vi.clearAllMocks(); + createWaSocketMock + .mockReset() + .mockImplementation( + async ( + _printQr: boolean, + _verbose: boolean, + opts?: { authDir?: string; onQr?: (qr: string) => void }, + ) => { + const sock = { ws: { close: vi.fn() } }; + if (opts?.onQr) { + setImmediate(() => opts.onQr?.("qr-data")); + } + return sock as never; + }, + ); + waitForWaConnectionMock.mockReset(); + readWebAuthExistsForDecisionMock.mockReset().mockResolvedValue({ + outcome: "stable", + exists: false, + }); + readWebSelfIdMock.mockReset().mockReturnValue({ e164: null, jid: null, lid: null }); + logoutWebMock.mockReset().mockResolvedValue(true); }); it("restarts login once on status 515 and completes", async () => { - let releaseCredsFlush: (() => void) | undefined; - const credsFlushGate = new Promise((resolve) => { - releaseCredsFlush = resolve; - }); waitForWaConnectionMock // Baileys v7 wraps the error: { error: BoomError(515) } .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) .mockResolvedValueOnce(undefined); - waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate); const start = await startWebLoginWithQr({ timeoutMs: 5000 }); expect(start.qrDataUrl).toBe("data:image/png;base64,base64"); @@ -80,11 +105,7 @@ describe("login-qr", () => { await flushTasks(); await flushTasks(); - expect(createWaSocketMock).toHaveBeenCalledTimes(1); - expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce(); - expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String)); - - releaseCredsFlush?.(); + expect(createWaSocketMock).toHaveBeenCalledTimes(2); const result = await resultPromise; expect(result.connected).toBe(true); @@ -126,4 +147,43 @@ describe("login-qr", () => { message: "WhatsApp login failed: cleanup failed", }); }); + + it("returns an unstable-auth result when creds flush does not settle", async () => { + readWebAuthExistsForDecisionMock.mockResolvedValueOnce({ outcome: "unstable" }); + + const result = await startWebLoginWithQr({ timeoutMs: 5000 }); + + expect(result).toEqual({ + code: WHATSAPP_AUTH_UNSTABLE_CODE, + message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + }); + expect(createWaSocketMock).not.toHaveBeenCalled(); + }); + + it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => { + createWaSocketMock.mockImplementationOnce( + async ( + _printQr: boolean, + _verbose: boolean, + _opts?: { authDir?: string; onQr?: (qr: string) => void }, + ) => + ({ + ws: { close: vi.fn() }, + }) as never, + ); + waitForWaConnectionMock.mockResolvedValueOnce(undefined); + readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null }); + + const result = await startWebLoginWithQr({ timeoutMs: 5000 }); + + expect(result).toEqual({ + connected: true, + message: "WhatsApp recovered the existing linked session (+5511977000000).", + }); + expect(createWaSocketMock).toHaveBeenCalledOnce(); + await expect(waitForWebLogin({ timeoutMs: 1000 })).resolves.toEqual({ + connected: false, + message: "No active WhatsApp login in progress.", + }); + }); }); diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index c5448b278b0..ff1d1354212 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -10,9 +10,20 @@ import { WHATSAPP_LOGGED_OUT_QR_MESSAGE, } from "./connection-controller.js"; import { renderQrPngBase64 } from "./qr-image.js"; -import { createWaSocket, readWebSelfId, webAuthExists } from "./session.js"; +import { + createWaSocket, + readWebAuthExistsForDecision, + readWebSelfId, + WHATSAPP_AUTH_UNSTABLE_CODE, +} from "./session.js"; type WaSocket = Awaited>; +export type StartWebLoginWithQrResult = { + qrDataUrl?: string; + message: string; + connected?: boolean; + code?: typeof WHATSAPP_AUTH_UNSTABLE_CODE; +}; type ActiveLogin = { accountId: string; @@ -31,6 +42,15 @@ type ActiveLogin = { runtime: RuntimeEnv; }; +type LoginQrRaceResult = + | { outcome: "qr"; qr: string } + | { outcome: "connected" } + | { outcome: "failed"; message: string }; + +function waitForNextTask(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + const ACTIVE_LOGIN_TTL_MS = 3 * 60_000; const activeLogins = new Map(); @@ -93,6 +113,52 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) { }); } +async function waitForQrOrRecoveredLogin(params: { + accountId: string; + login: ActiveLogin; + qrPromise: Promise; +}): Promise { + const qrResult = params.qrPromise.then( + (qr) => ({ outcome: "qr", qr }) as const, + (err) => + ({ + outcome: "failed", + message: `Failed to get QR: ${String(err)}`, + }) as const, + ); + const loginResult = params.login.waitPromise.then(async () => { + const current = activeLogins.get(params.accountId); + if (current?.id !== params.login.id) { + return { + outcome: "failed", + message: "WhatsApp login was replaced by a newer request.", + } as const; + } + + // A QR may already be queued for the next task even if the login waiter won first. + await waitForNextTask(); + const latest = activeLogins.get(params.accountId); + if (latest?.id !== params.login.id) { + return { + outcome: "failed", + message: "WhatsApp login was replaced by a newer request.", + } as const; + } + if (latest.qr) { + return { outcome: "qr", qr: latest.qr } as const; + } + if (latest.connected) { + return { outcome: "connected" } as const; + } + return { + outcome: "failed", + message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.", + } as const; + }); + + return await Promise.race([qrResult, loginResult]); +} + export async function startWebLoginWithQr( opts: { verbose?: boolean; @@ -101,13 +167,19 @@ export async function startWebLoginWithQr( accountId?: string; runtime?: RuntimeEnv; } = {}, -): Promise<{ qrDataUrl?: string; message: string }> { +): Promise { const runtime = opts.runtime ?? defaultRuntime; const cfg = loadConfig(); const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); - const hasWeb = await webAuthExists(account.authDir); - const selfId = readWebSelfId(account.authDir); - if (hasWeb && !opts.force) { + const authState = await readWebAuthExistsForDecision(account.authDir); + if (authState.outcome === "unstable") { + return { + code: WHATSAPP_AUTH_UNSTABLE_CODE, + message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + }; + } + if (authState.exists && !opts.force) { + const selfId = readWebSelfId(account.authDir); const who = selfId.e164 ?? selfId.jid ?? "unknown"; return { message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`, @@ -182,18 +254,31 @@ export async function startWebLoginWithQr( } attachLoginWaiter(account.accountId, login); - let qr: string; - try { - qr = await qrPromise; - } catch (err) { - clearTimeout(qrTimer); + const loginStartResult = await waitForQrOrRecoveredLogin({ + accountId: account.accountId, + login, + qrPromise, + }); + clearTimeout(qrTimer); + + if (loginStartResult.outcome === "connected") { + const selfId = readWebSelfId(account.authDir); + const who = selfId.e164 ?? selfId.jid ?? "unknown"; await resetActiveLogin(account.accountId); return { - message: `Failed to get QR: ${String(err)}`, + message: `WhatsApp recovered the existing linked session (${who}).`, + connected: true, }; } - const base64 = await renderQrPngBase64(qr); + if (loginStartResult.outcome === "failed") { + await resetActiveLogin(account.accountId); + return { + message: loginStartResult.message, + }; + } + + const base64 = await renderQrPngBase64(loginStartResult.qr); login.qrDataUrl = `data:image/png;base64,${base64}`; return { qrDataUrl: login.qrDataUrl, diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index f572cb9b81d..e05823236ca 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -2,12 +2,7 @@ import { rmSync } from "node:fs"; import fs from "node:fs/promises"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loginWeb } from "./login.js"; -import { - createWaSocket, - formatError, - waitForCredsSaveQueueWithTimeout, - waitForWaConnection, -} from "./session.js"; +import { createWaSocket, formatError, waitForWaConnection } from "./session.js"; const rmMock = vi.spyOn(fs, "rm"); const testState = vi.hoisted(() => ({ @@ -51,14 +46,12 @@ vi.mock("./session.js", async () => { (err as { status?: number })?.status ?? (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode, ); - const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {}); return { ...actual, createWaSocket, waitForWaConnection, formatError, getStatusCode, - waitForCredsSaveQueueWithTimeout, WA_WEB_AUTH_DIR: authDir, logoutWeb: vi.fn(async (params: { authDir?: string }) => { await fs.rm(params.authDir ?? authDir, { @@ -72,7 +65,6 @@ vi.mock("./session.js", async () => { const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); -const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout); const formatErrorMock = vi.mocked(formatError); async function flushTasks() { @@ -86,7 +78,6 @@ describe("loginWeb coverage", () => { vi.clearAllMocks(); createWaSocketMock.mockClear(); waitForWaConnectionMock.mockReset().mockResolvedValue(undefined); - waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined); formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`); rmMock.mockClear(); }); @@ -99,24 +90,15 @@ describe("loginWeb coverage", () => { }); it("restarts once when WhatsApp requests code 515", async () => { - let releaseCredsFlush: (() => void) | undefined; - const credsFlushGate = new Promise((resolve) => { - releaseCredsFlush = resolve; - }); waitForWaConnectionMock .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) .mockResolvedValueOnce(undefined); - waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate); const runtime = { log: vi.fn(), error: vi.fn() } as never; const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime); await flushTasks(); - expect(createWaSocketMock).toHaveBeenCalledTimes(1); - expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce(); - expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(testState.authDir); - - releaseCredsFlush?.(); + expect(createWaSocketMock).toHaveBeenCalledTimes(2); await pendingLogin; expect(createWaSocketMock).toHaveBeenCalledTimes(2); diff --git a/extensions/whatsapp/src/login.test.ts b/extensions/whatsapp/src/login.test.ts index df063e8adda..deb640cfe89 100644 --- a/extensions/whatsapp/src/login.test.ts +++ b/extensions/whatsapp/src/login.test.ts @@ -21,14 +21,24 @@ vi.mock("./session.js", async () => { }; }); +vi.mock("./auth-store.js", async () => { + const actual = await vi.importActual("./auth-store.js"); + return { + ...actual, + restoreCredsFromBackupIfNeeded: vi.fn(async () => false), + }; +}); + import type { waitForWaConnection } from "./session.js"; let loginWeb: typeof import("./login.js").loginWeb; let createWaSocket: typeof import("./session.js").createWaSocket; +let restoreCredsFromBackupIfNeeded: typeof import("./auth-store.js").restoreCredsFromBackupIfNeeded; describe("web login", () => { beforeAll(async () => { ({ loginWeb } = await import("./login.js")); ({ createWaSocket } = await import("./session.js")); + ({ restoreCredsFromBackupIfNeeded } = await import("./auth-store.js")); }); beforeEach(() => { @@ -57,6 +67,19 @@ describe("web login", () => { await vi.advanceTimersByTimeAsync(1); expect(close).toHaveBeenCalledTimes(1); }); + + it("prints a backup recovery success message when creds are restored from backup", async () => { + const waiter: typeof waitForWaConnection = vi.fn().mockResolvedValue(undefined); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(restoreCredsFromBackupIfNeeded).mockResolvedValueOnce(true); + + await loginWeb(false, waiter); + + expect(consoleLog).toHaveBeenCalledWith( + expect.stringContaining("✅ Recovered from creds.json.bak; web session ready."), + ); + consoleLog.mockRestore(); + }); }); describe("renderQrPngBase64", () => { diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index 50010f0c411..fcaf7927b2d 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -4,6 +4,7 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { logInfo } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; +import { restoreCredsFromBackupIfNeeded } from "./auth-store.js"; import { closeWaSocketSoon, waitForWhatsAppLoginResult } from "./connection-controller.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; @@ -15,6 +16,7 @@ export async function loginWeb( ) { const cfg = loadConfig(); const account = resolveWhatsAppAccount({ cfg, accountId }); + const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir); let sock = await createWaSocket(true, verbose, { authDir: account.authDir, }); @@ -36,7 +38,9 @@ export async function loginWeb( success( result.restarted ? "✅ Linked after restart; web session ready." - : "✅ Linked! Credentials saved for future sends.", + : restoredFromBackup + ? "✅ Recovered from creds.json.bak; web session ready." + : "✅ Linked! Credentials saved for future sends.", ), ); return; diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 4821e413213..4b107678c30 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -34,6 +34,55 @@ function createTempAuthDir(prefix: string) { ); } +function mockFsOpenForCredsWrites(params?: { + onTempWrite?: (filePath: string) => Promise | void; +}) { + const open = fs.open.bind(fs); + const tempHandles: Array<{ + filePath: string; + writeFile: ReturnType; + sync: ReturnType; + close: ReturnType; + }> = []; + const dirHandles: Array<{ + filePath: string; + sync: ReturnType; + close: ReturnType; + }> = []; + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { + if (typeof filePath === "string" && flags === "w" && filePath.includes(".creds.")) { + const handle = { + filePath, + writeFile: vi.fn(async () => { + await params?.onTempWrite?.(filePath); + }), + sync: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; + tempHandles.push(handle); + return handle as never; + } + if (typeof filePath === "string" && flags === "r") { + const handle = { + filePath, + sync: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; + dirHandles.push(handle); + return handle as never; + } + return open(filePath as never, flags as never, mode as never); + }); + return { + openSpy, + tempHandles, + dirHandles, + restore() { + openSpy.mockRestore(); + }, + }; +} + function mockCredsJsonSpies(readContents: string) { const credsSuffix = path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"); const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {}); @@ -116,7 +165,7 @@ describe("web session", () => { it("creates WA socket with QR handler", async () => { const authDir = createTempAuthDir("openclaw-wa-creds-test"); - const writeFileSpy = vi.spyOn(fs, "writeFile"); + const openMock = mockFsOpenForCredsWrites(); await createWaSocket(true, false, { authDir }); const makeWASocket = baileys.makeWASocket as ReturnType; @@ -129,12 +178,12 @@ describe("web session", () => { expect(typeof passedLogger?.trace).toBe("function"); await emitCredsUpdate(authDir); - expect(writeFileSpy).toHaveBeenCalledWith( + expect(openMock.openSpy).toHaveBeenCalledWith( expect.stringContaining(path.join(authDir, ".creds.")), - expect.any(String), - expect.objectContaining({ mode: 0o600 }), + "w", + 0o600, ); - writeFileSpy.mockRestore(); + openMock.restore(); }); it("uses ambient env proxy agent when HTTPS_PROXY is configured", async () => { @@ -253,16 +302,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"); + const openMock = mockFsOpenForCredsWrites(); await createWaSocket(false, false); await emitCredsUpdate(); expect(creds.copySpy).not.toHaveBeenCalled(); - expect(writeFileSpy).toHaveBeenCalled(); + expect(openMock.tempHandles).toHaveLength(1); creds.restore(); - writeFileSpy.mockRestore(); + openMock.restore(); }); it("serializes creds.update saves to avoid overlapping writes", async () => { @@ -274,19 +323,16 @@ describe("web session", () => { }); 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.")) { + const openMock = mockFsOpenForCredsWrites({ + onTempWrite: async (filePath) => { + if (filePath.startsWith(authDir)) { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); await gate; inFlight -= 1; - return; } - return writeFile(file, data, options as never); - }); + }, + }); await createWaSocket(false, false, { authDir }); const sock = getLastSocket(); @@ -301,10 +347,10 @@ describe("web session", () => { await waitForCredsSaveQueue(authDir); - expect(writeFileSpy).toHaveBeenCalledTimes(2); + expect(openMock.tempHandles).toHaveLength(2); expect(maxInFlight).toBe(1); expect(inFlight).toBe(0); - writeFileSpy.mockRestore(); + openMock.restore(); }); it("lets different authDir queues flush independently", async () => { @@ -321,24 +367,21 @@ describe("web session", () => { 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.")) { + const openMock = mockFsOpenForCredsWrites({ + onTempWrite: async (filePath) => { + if (filePath.startsWith(authDirA)) { inFlightA += 1; await gateA; inFlightA -= 1; return; } - if (typeof file === "string" && file.startsWith(authDirB) && file.includes(".creds.")) { + if (filePath.startsWith(authDirB)) { inFlightB += 1; await gateB; inFlightB -= 1; - return; } - return writeFile(file, data, options as never); - }); + }, + }); await createWaSocket(false, false, { authDir: authDirA }); const sockA = getLastSocket(); @@ -350,7 +393,7 @@ describe("web session", () => { await flushCredsUpdate(); - expect(writeFileSpy).toHaveBeenCalledTimes(2); + expect(openMock.tempHandles).toHaveLength(2); expect(inFlightA).toBe(1); expect(inFlightB).toBe(1); @@ -360,12 +403,12 @@ describe("web session", () => { expect(inFlightA).toBe(0); expect(inFlightB).toBe(0); - writeFileSpy.mockRestore(); + openMock.restore(); }); it("rotates creds backup when creds.json is valid JSON", async () => { const creds = mockCredsJsonSpies("{}"); - const writeFileSpy = vi.spyOn(fs, "writeFile"); + const openMock = mockFsOpenForCredsWrites(); const backupSuffix = path.join( "/tmp", "openclaw-oauth", @@ -381,27 +424,32 @@ describe("web session", () => { const args = creds.copySpy.mock.calls[0] ?? []; expect(String(args[0] ?? "")).toContain(creds.credsSuffix); expect(String(args[1] ?? "")).toContain(backupSuffix); - expect(writeFileSpy).toHaveBeenCalled(); + expect(openMock.tempHandles).toHaveLength(1); creds.restore(); - writeFileSpy.mockRestore(); + openMock.restore(); }); it("writes creds.json atomically via temp file and rename", async () => { - const writeFileSpy = vi.spyOn(fs, "writeFile").mockResolvedValue(undefined); + const openMock = mockFsOpenForCredsWrites(); const renameSpy = vi.spyOn(fs, "rename").mockResolvedValue(undefined); const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); - const chmodSpy = vi.spyOn(fsSync, "chmodSync").mockImplementation(() => {}); + const chmodSpy = vi.spyOn(fs, "chmod").mockResolvedValue(undefined); await writeCredsJsonAtomically("/tmp/openclaw-oauth/whatsapp/default", { me: { id: "123@s.whatsapp.net" }, }); - expect(writeFileSpy).toHaveBeenCalledTimes(1); + expect(openMock.tempHandles).toHaveLength(1); + expect(openMock.tempHandles[0]?.writeFile).toHaveBeenCalledTimes(1); + expect(openMock.tempHandles[0]?.sync).toHaveBeenCalledTimes(1); + expect(openMock.tempHandles[0]?.close).toHaveBeenCalledTimes(1); expect(renameSpy).toHaveBeenCalledTimes(1); expect(rmSpy).not.toHaveBeenCalled(); - expect(chmodSpy).not.toHaveBeenCalled(); - const writePath = writeFileSpy.mock.calls[0]?.[0]; + expect(chmodSpy).toHaveBeenCalledOnce(); + expect(openMock.dirHandles).toHaveLength(1); + expect(openMock.dirHandles[0]?.sync).toHaveBeenCalledTimes(1); + const writePath = openMock.tempHandles[0]?.filePath; const renameArgs = renameSpy.mock.calls[0] ?? []; expect(typeof writePath).toBe("string"); expect(writePath).toContain(".creds."); @@ -409,7 +457,7 @@ describe("web session", () => { path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"), ); - writeFileSpy.mockRestore(); + openMock.restore(); renameSpy.mockRestore(); rmSpy.mockRestore(); chmodSpy.mockRestore(); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 3449423d6c3..f726bec1c7c 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,8 +1,6 @@ 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"; @@ -10,15 +8,21 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import { - maybeRestoreCredsFromBackup, readCredsJsonRaw, + restoreCredsFromBackupIfNeeded, resolveDefaultWebAuthDir, resolveWebCredsBackupPath, resolveWebCredsPath, } from "./auth-store.js"; +import { + enqueueCredsSave, + waitForCredsSaveQueue, + waitForCredsSaveQueueWithTimeout, + writeCredsJsonAtomically, + type CredsQueueWaitResult, +} from "./creds-persistence.js"; import { formatError, getStatusCode } from "./session-errors.js"; import { - BufferJSON, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, @@ -32,54 +36,47 @@ export { logoutWeb, logWebSelfId, pickWebChannel, + readWebAuthSnapshot, + readWebAuthState, + readWebAuthExistsBestEffort, + readWebAuthExistsForDecision, + readWebAuthSnapshotBestEffort, + readWebSelfIdentityForDecision, readWebSelfId, + WHATSAPP_AUTH_UNSTABLE_CODE, + WhatsAppAuthUnstableError, + type WhatsAppWebAuthState, WA_WEB_AUTH_DIR, webAuthExists, } from "./auth-store.js"; +export { + waitForCredsSaveQueue, + waitForCredsSaveQueueWithTimeout, + writeCredsJsonAtomically, +} from "./creds-persistence.js"; +export type { CredsQueueWaitResult } from "./creds-persistence.js"; const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; +const CREDS_FLUSH_TIMEOUT_MESSAGE = + "Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds."; async function loadQrTerminal() { const mod = await import("qrcode-terminal"); 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; function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, logger: ReturnType, ): void { - const prev = credsSaveQueues.get(authDir) ?? Promise.resolve(); - const next = prev - .then(() => safeSaveCreds(authDir, saveCreds, logger)) - .catch((err) => { + enqueueCredsSave( + authDir, + () => safeSaveCreds(authDir, saveCreds, logger), + (err) => { logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); - }) - .finally(() => { - if (credsSaveQueues.get(authDir) === next) { - credsSaveQueues.delete(authDir); - } - }); - credsSaveQueues.set(authDir, next); + }, + ); } async function safeSaveCreds( @@ -135,7 +132,12 @@ export async function createWaSocket( const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir()); await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); - maybeRestoreCredsFromBackup(authDir); + const queueResult = await waitForCredsSaveQueueWithTimeout(authDir); + if (queueResult === "timed_out") { + sessionLogger.warn({ authDir }, CREDS_FLUSH_TIMEOUT_MESSAGE); + } else { + await restoreCredsFromBackupIfNeeded(authDir); + } const { state } = await useMultiFileAuthState(authDir); const saveCreds = async () => { await writeCredsJsonAtomically(authDir, state.creds); @@ -219,18 +221,6 @@ async function resolveEnvProxyAgent( }); } -type UndiciProxyAgentsModule = Pick; - -let undiciProxyAgentsModulePromise: Promise | null = null; - -async function loadUndiciProxyAgents(): Promise { - undiciProxyAgentsModulePromise ??= import("undici").then(({ EnvHttpProxyAgent, ProxyAgent }) => ({ - EnvHttpProxyAgent, - ProxyAgent, - })); - return undiciProxyAgentsModulePromise; -} - async function resolveEnvFetchDispatcher( logger: ReturnType, agent?: unknown, @@ -241,7 +231,7 @@ async function resolveEnvFetchDispatcher( return undefined; } try { - const { EnvHttpProxyAgent, ProxyAgent } = await loadUndiciProxyAgents(); + const { EnvHttpProxyAgent, ProxyAgent } = await import("undici"); return proxyUrl ? new ProxyAgent({ allowH2: false, uri: proxyUrl }) : new EnvHttpProxyAgent({ allowH2: false }); @@ -306,32 +296,6 @@ export async function waitForWaConnection(sock: ReturnType) }); } -/** Await pending credential saves — scoped to one authDir, or all if omitted. */ -export function waitForCredsSaveQueue(authDir?: string): Promise { - if (authDir) { - return credsSaveQueues.get(authDir) ?? Promise.resolve(); - } - return Promise.all(credsSaveQueues.values()).then(() => {}); -} - -/** Await pending credential saves, but don't hang forever on stalled I/O. */ -export async function waitForCredsSaveQueueWithTimeout( - authDir: string, - timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS, -): Promise { - let flushTimeout: ReturnType | undefined; - await Promise.race([ - waitForCredsSaveQueue(authDir), - new Promise((resolve) => { - flushTimeout = setTimeout(resolve, timeoutMs); - }), - ]).finally(() => { - if (flushTimeout) { - clearTimeout(flushTimeout); - } - }); -} - export function newConnectionId() { return randomUUID(); } diff --git a/extensions/whatsapp/src/setup-surface.test.ts b/extensions/whatsapp/src/setup-surface.test.ts index d70c422ad5e..89d73bbc3ef 100644 --- a/extensions/whatsapp/src/setup-surface.test.ts +++ b/extensions/whatsapp/src/setup-surface.test.ts @@ -31,7 +31,12 @@ const hoisted = vi.hoisted(() => ({ ), loginWeb: vi.fn(async () => {}), pathExists: vi.fn(async () => false), - resolveWhatsAppAuthDir: vi.fn(() => ({ + readWebAuthState: vi.fn<(authDir: string) => Promise<"linked" | "not-linked" | "unstable">>( + async () => "not-linked", + ), + resolveWhatsAppAuthDir: vi.fn< + (params: { cfg: OpenClawConfig; accountId: string }) => { authDir: string } + >(() => ({ authDir: "/tmp/openclaw-whatsapp-test", })), })); @@ -66,6 +71,14 @@ vi.mock("./accounts.js", async () => { }; }); +vi.mock("./auth-store.js", async () => { + const actual = await vi.importActual("./auth-store.js"); + return { + ...actual, + readWebAuthState: hoisted.readWebAuthState, + }; +}); + function createRuntime(): RuntimeEnv { return { error: vi.fn(), @@ -136,6 +149,8 @@ describe("whatsapp setup wizard", () => { hoisted.loginWeb.mockReset(); hoisted.pathExists.mockReset(); hoisted.pathExists.mockResolvedValue(false); + hoisted.readWebAuthState.mockReset(); + hoisted.readWebAuthState.mockResolvedValue("not-linked"); hoisted.resolveWhatsAppAuthDir.mockReset(); hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" }); }); @@ -203,8 +218,11 @@ describe("whatsapp setup wizard", () => { }); it("uses configured defaultAccount for omitted-account setup status", async () => { - hoisted.detectWhatsAppLinked.mockImplementation( - async (_cfg: OpenClawConfig, accountId: string) => accountId === "work", + hoisted.resolveWhatsAppAuthDir.mockImplementation(({ accountId }: { accountId: string }) => ({ + authDir: accountId === "work" ? "/tmp/work" : "/tmp/default", + })); + hoisted.readWebAuthState.mockImplementation(async (authDir: string) => + authDir === "/tmp/work" ? "linked" : "not-linked", ); const status = await whatsappGetStatus({ @@ -228,11 +246,33 @@ describe("whatsapp setup wizard", () => { expect(status.configured).toBe(true); expect(status.statusLines).toEqual(["WhatsApp (work): linked"]); - expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith( - expect.any(Object), - DEFAULT_ACCOUNT_ID, - ); - expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith(expect.any(Object), "work"); + expect(hoisted.readWebAuthState).toHaveBeenCalledWith("/tmp/default"); + expect(hoisted.readWebAuthState).toHaveBeenCalledWith("/tmp/work"); + }); + + it("shows auth stabilizing when auth reads time out", async () => { + hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/work" }); + hoisted.readWebAuthState.mockResolvedValue("unstable"); + + const status = await whatsappGetStatus({ + cfg: { + channels: { + whatsapp: { + accounts: { + work: { + authDir: "/tmp/work", + }, + }, + }, + }, + } as OpenClawConfig, + accountOverrides: { + whatsapp: "work", + }, + }); + + expect(status.configured).toBe(false); + expect(status.statusLines).toEqual(["WhatsApp (work): auth stabilizing"]); }); it("uses configured defaultAccount for omitted-account finalize writes", async () => { diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index d6ce5ddc3a4..e931dd068fc 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,10 +1,25 @@ import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { DEFAULT_ACCOUNT_ID, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; -import { listWhatsAppAccountIds } from "./accounts.js"; -import { detectWhatsAppLinked, finalizeWhatsAppSetup } from "./setup-finalize.js"; +import { + DEFAULT_ACCOUNT_ID, + setSetupChannelEnabled, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; +import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; +import { formatWhatsAppWebAuthStatusState, readWebAuthState } from "./auth-store.js"; +import { finalizeWhatsAppSetup } from "./setup-finalize.js"; const channel = "whatsapp" as const; +type WhatsAppSetupLinkState = "linked" | "not-linked" | "unstable"; + +async function readWhatsAppSetupLinkState( + cfg: OpenClawConfig, + accountId: string, +): Promise { + const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); + return await readWebAuthState(authDir); +} + export const whatsappSetupWizard: ChannelSetupWizard = { channel, status: { @@ -16,7 +31,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = { unconfiguredScore: 4, resolveConfigured: async ({ cfg, accountId }) => { for (const resolvedAccountId of accountId ? [accountId] : listWhatsAppAccountIds(cfg)) { - if (await detectWhatsAppLinked(cfg, resolvedAccountId)) { + if ((await readWhatsAppSetupLinkState(cfg, resolvedAccountId)) === "linked") { return true; } } @@ -28,16 +43,19 @@ export const whatsappSetupWizard: ChannelSetupWizard = { (accountId ? [accountId] : listWhatsAppAccountIds(cfg)).map( async (resolvedAccountId) => ({ accountId: resolvedAccountId, - linked: await detectWhatsAppLinked(cfg, resolvedAccountId), + state: await readWhatsAppSetupLinkState(cfg, resolvedAccountId), }), ), ) - ).find((entry) => entry.linked)?.accountId; - const labelAccountId = accountId ?? linkedAccountId; + ).find((entry) => entry.state === "linked" || entry.state === "unstable"); + const labelAccountId = accountId ?? linkedAccountId?.accountId; const label = labelAccountId ? `WhatsApp (${labelAccountId === DEFAULT_ACCOUNT_ID ? "default" : labelAccountId})` : "WhatsApp"; - return [`${label}: ${configured ? "linked" : "not linked"}`]; + const stateLabel = configured + ? formatWhatsAppWebAuthStatusState("linked") + : formatWhatsAppWebAuthStatusState(linkedAccountId?.state ?? "not-linked"); + return [`${label}: ${stateLabel}`]; }, }, resolveShouldPromptAccountIds: ({ shouldPromptAccountIds }) => shouldPromptAccountIds, diff --git a/extensions/whatsapp/src/status-issues.test.ts b/extensions/whatsapp/src/status-issues.test.ts index c637de20fcd..9ad9d509400 100644 --- a/extensions/whatsapp/src/status-issues.test.ts +++ b/extensions/whatsapp/src/status-issues.test.ts @@ -20,6 +20,25 @@ describe("collectWhatsAppStatusIssues", () => { ]); }); + it("reports auth reads that are still stabilizing", () => { + const issues = collectWhatsAppStatusIssues([ + { + accountId: "default", + enabled: true, + statusState: "unstable", + }, + ]); + + expect(issues).toEqual([ + expect.objectContaining({ + channel: "whatsapp", + accountId: "default", + kind: "auth", + message: "Auth state is still stabilizing.", + }), + ]); + }); + it("reports linked but disconnected runtime state", () => { const issues = collectWhatsAppStatusIssues([ { diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts index a3050359816..c5bdc41fcd9 100644 --- a/extensions/whatsapp/src/status-issues.ts +++ b/extensions/whatsapp/src/status-issues.ts @@ -11,6 +11,7 @@ import { type WhatsAppAccountStatus = { accountId?: unknown; + statusState?: unknown; enabled?: unknown; linked?: unknown; connected?: unknown; @@ -27,6 +28,7 @@ function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccou } return { accountId: value.accountId, + statusState: value.statusState, enabled: value.enabled, linked: value.linked, connected: value.connected, @@ -46,6 +48,7 @@ export function collectWhatsAppStatusIssues( readAccount: readWhatsAppAccountStatus, collectIssues: ({ account, accountId, issues }) => { const linked = account.linked === true; + const statusState = asString(account.statusState); const running = account.running === true; const connected = account.connected === true; const reconnectAttempts = @@ -55,6 +58,17 @@ export function collectWhatsAppStatusIssues( const lastError = asString(account.lastError); const healthState = asString(account.healthState); + if (statusState === "unstable") { + issues.push({ + channel: "whatsapp", + accountId, + kind: "auth", + message: "Auth state is still stabilizing.", + fix: "Wait a moment for queued credential writes to finish, then retry the command or rerun health.", + }); + return; + } + if (!linked) { issues.push({ channel: "whatsapp", diff --git a/src/channels/plugins/status-state.ts b/src/channels/plugins/status-state.ts new file mode 100644 index 00000000000..150214f59a3 --- /dev/null +++ b/src/channels/plugins/status-state.ts @@ -0,0 +1,12 @@ +export function formatChannelStatusState(statusState: string): string { + switch (statusState) { + case "linked": + return "linked"; + case "not-linked": + return "not linked"; + case "unstable": + return "auth stabilizing"; + default: + return statusState; + } +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 6241bf4bcd7..792d39f02a2 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -317,6 +317,7 @@ export type ChannelLogoutResult = { export type ChannelLoginWithQrStartResult = { qrDataUrl?: string; message: string; + connected?: boolean; }; export type ChannelLoginWithQrWaitResult = { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index d21d1840a7f..8e78f9e3617 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -180,6 +180,7 @@ export type ChannelAccountSnapshot = { name?: string; enabled?: boolean; configured?: boolean; + statusState?: string; linked?: boolean; running?: boolean; connected?: boolean; diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 1bd79402b22..84a7e31085b 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ applyPluginAutoEnable: vi.fn(), replaceConfigFile: vi.fn(), setVerbose: vi.fn(), + callGateway: vi.fn(), createClackPrompter: vi.fn(), ensureChannelSetupPluginInstalled: vi.fn(), loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), @@ -57,6 +58,10 @@ vi.mock("../globals.js", () => ({ setVerbose: mocks.setVerbose, })); +vi.mock("../gateway/call.js", () => ({ + callGateway: mocks.callGateway, +})); + vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); @@ -72,7 +77,7 @@ describe("channel-auth", () => { const plugin = { id: "whatsapp", auth: { login: mocks.login }, - gateway: { logoutAccount: mocks.logoutAccount }, + gateway: { startAccount: vi.fn(), logoutAccount: mocks.logoutAccount }, config: { listAccountIds: vi.fn().mockReturnValue(["default"]), resolveAccount: mocks.resolveAccount, @@ -89,6 +94,7 @@ describe("channel-auth", () => { mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); mocks.replaceConfigFile.mockResolvedValue(undefined); + mocks.callGateway.mockResolvedValue({ ok: true }); mocks.listChannelPlugins.mockReturnValue([plugin]); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); @@ -122,6 +128,41 @@ describe("channel-auth", () => { channelInput: "wa", }), ); + expect(mocks.callGateway).toHaveBeenCalledWith({ + config: { channels: { whatsapp: {} } }, + method: "channels.start", + params: { + channel: "whatsapp", + accountId: "acct-1", + }, + mode: "backend", + clientName: "gateway-client", + deviceIdentity: null, + }); + }); + + it("skips gateway runtime reconcile in remote mode and warns without failing login", async () => { + mocks.loadConfig.mockReturnValue({ + gateway: { mode: "remote" }, + channels: { whatsapp: {} }, + }); + + await runChannelLogin({ channel: "whatsapp", account: "acct-1" }, runtime); + + expect(mocks.callGateway).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Gateway is in remote mode")); + }); + + it("keeps login successful when local gateway runtime reconcile fails", async () => { + mocks.callGateway.mockRejectedValue(new Error("gateway unreachable")); + + await expect( + runChannelLogin({ channel: "whatsapp", account: "acct-1" }, runtime), + ).resolves.toBeUndefined(); + + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("running gateway did not restart it: gateway unreachable"), + ); }); it("auto-picks the single configured channel that supports login when opts are empty", async () => { diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index f1d30f1f80e..edb1825ffc5 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -12,11 +12,14 @@ import { type OpenClawConfig, } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import { callGateway } from "../gateway/call.js"; import { setVerbose } from "../globals.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; type ChannelAuthOptions = { channel?: string; @@ -134,6 +137,41 @@ function resolveAccountContext( return { accountId }; } +async function reconcileGatewayRuntimeAfterLocalLogin(params: { + cfg: OpenClawConfig; + plugin: ChannelPlugin; + channelId: string; + accountId: string; + runtime: RuntimeEnv; +}) { + if (!params.plugin.gateway?.startAccount) { + return; + } + if (params.cfg.gateway?.mode === "remote") { + params.runtime.log( + `Gateway is in remote mode; local login saved auth for ${params.channelId}/${params.accountId} but did not start the remote runtime.`, + ); + return; + } + try { + await callGateway({ + config: params.cfg, + method: "channels.start", + params: { + channel: params.channelId, + accountId: params.accountId, + }, + mode: GATEWAY_CLIENT_MODES.BACKEND, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + deviceIdentity: null, + }); + } catch (error) { + params.runtime.log( + `Local login saved auth for ${params.channelId}/${params.accountId}, but the running gateway did not restart it: ${formatErrorMessage(error)}`, + ); + } +} + export async function runChannelLogin( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, @@ -170,6 +208,13 @@ export async function runChannelLogin( verbose: Boolean(opts.verbose), channelInput, }); + await reconcileGatewayRuntimeAfterLocalLogin({ + cfg, + plugin, + channelId: plugin.id, + accountId, + runtime, + }); } export async function runChannelLogout( diff --git a/src/commands/health-format.ts b/src/commands/health-format.ts index 3050cc05022..92a2d6e385f 100644 --- a/src/commands/health-format.ts +++ b/src/commands/health-format.ts @@ -1,4 +1,5 @@ import { getChannelPlugin } from "../channels/plugins/index.js"; +import { formatChannelStatusState } from "../channels/plugins/status-state.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import type { ChannelAccountHealthSummary, HealthSummary } from "./health.types.js"; @@ -171,6 +172,19 @@ export const formatHealthChannelLines = ( }) .filter((value): value is string => Boolean(value)) : []; + const statusState = + typeof baseSummary.statusState === "string" ? baseSummary.statusState : null; + if (statusState) { + if (statusState === "linked") { + const authAgeMs = typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null; + const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : ""; + lines.push(`${label}: ${formatChannelStatusState(statusState)}${authLabel}`); + } else { + lines.push(`${label}: ${formatChannelStatusState(statusState)}`); + } + continue; + } + const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null; if (linked !== null) { if (linked) { diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index e3913e75fb3..c5ad3f5cce9 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -132,6 +132,23 @@ describe("healthCommand", () => { "Telegram: ok (@pinguini_ugi_bot:main:196ms, @flurry_ugi_bot:flurry:190ms, @poe_ugi_bot:poe:188ms)", ); }); + + it("formats statusState without inferring from linked", () => { + const summary = createHealthSummary({ + channels: { + whatsapp: { + accountId: "default", + statusState: "unstable", + configured: true, + }, + }, + channelOrder: ["whatsapp"], + channelLabels: { whatsapp: "WhatsApp" }, + }); + + const lines = formatHealthChannelLines(summary, { accountMode: "default" }); + expect(lines).toContain("WhatsApp: auth stabilizing"); + }); }); describe("formatHealthCheckFailure", () => { diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index 7dd2442e5ae..730e2e1694f 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -11,6 +11,7 @@ import { } from "../../channels/account-summary.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { formatChannelStatusState } from "../../channels/plugins/status-state.js"; import type { ChannelAccountSnapshot, ChannelId, @@ -188,16 +189,18 @@ const buildAccountNotes = (params: { }; function resolveLinkFields(summary: unknown): { + statusState: string | null; linked: boolean | null; authAgeMs: number | null; selfE164: string | null; } { const rec = asRecord(summary); + const statusState = typeof rec.statusState === "string" ? rec.statusState : null; const linked = typeof rec.linked === "boolean" ? rec.linked : null; const authAgeMs = typeof rec.authAgeMs === "number" ? rec.authAgeMs : null; const self = asRecord(rec.self); const selfE164 = typeof self.e164 === "string" && self.e164.trim() ? self.e164.trim() : null; - return { linked, authAgeMs, selfE164 }; + return { statusState, linked, authAgeMs, selfE164 }; } function collectMissingPaths(accounts: ChannelAccountRow[]): string[] { @@ -311,6 +314,9 @@ export async function buildChannelsTable( if (unavailableConfiguredAccounts.length > 0) { return "warn"; } + if (link.statusState === "unstable") { + return "warn"; + } if (link.linked === false) { return "setup"; } @@ -339,6 +345,24 @@ export async function buildChannelsTable( if (issues.length > 0) { return issues[0]?.message ?? "misconfigured"; } + if (link.statusState) { + if (link.statusState === "linked") { + const extra: string[] = []; + if (link.selfE164) { + extra.push(link.selfE164); + } + if (link.authAgeMs != null && link.authAgeMs >= 0) { + extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`); + } + if (accounts.length > 1 || plugin.meta.forceAccountBinding) { + extra.push(`accounts ${accounts.length || 1}`); + } + return extra.length > 0 + ? `${formatChannelStatusState(link.statusState)} · ${extra.join(" · ")}` + : formatChannelStatusState(link.statusState); + } + return formatChannelStatusState(link.statusState); + } if (link.linked !== null) { const base = link.linked ? "linked" : "not linked"; diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 1ecb8576a1d..927ccd3adac 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -330,6 +330,20 @@ describe("callGateway url resolution", () => { expect(lastRequestOptions?.method).toBe("health"); }); + it("honors an explicit null device identity override", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ + method: "health", + token: "explicit-token", + deviceIdentity: null, + }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeNull(); + }); + it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index a31d7c328f5..51a5d6cc4fc 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -6,7 +6,7 @@ import { resolveStateDir as resolveStateDirFromPaths, } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { @@ -54,6 +54,7 @@ type CallGatewayBaseOptions = { clientVersion?: string; platform?: string; mode?: GatewayClientMode; + deviceIdentity?: DeviceIdentity | null; instanceId?: string; minProtocol?: number; maxProtocol?: number; @@ -491,7 +492,10 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: resolveDeviceIdentityForGatewayCall(), + deviceIdentity: + opts.deviceIdentity === undefined + ? resolveDeviceIdentityForGatewayCall() + : opts.deviceIdentity, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 0dcc8c992f8..377dab74433 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -147,6 +147,7 @@ const METHOD_SCOPE_GROUPS: Record = { "node.pending.enqueue", ], [ADMIN_SCOPE]: [ + "channels.start", "channels.logout", "agents.create", "agents.update", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 8b0b6f46d74..7945e0fb677 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -44,6 +44,8 @@ import { AgentsListResultSchema, type AgentWaitParams, AgentWaitParamsSchema, + type ChannelsStartParams, + ChannelsStartParamsSchema, type ChannelsLogoutParams, ChannelsLogoutParamsSchema, type TalkConfigParams, @@ -431,6 +433,8 @@ export const validateTalkSpeakResult = ajv.compile(TalkSpeakRes export const validateChannelsStatusParams = ajv.compile( ChannelsStatusParamsSchema, ); +export const validateChannelsStartParams = + ajv.compile(ChannelsStartParamsSchema); export const validateChannelsLogoutParams = ajv.compile( ChannelsLogoutParamsSchema, ); @@ -616,6 +620,7 @@ export { TalkSpeakResultSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, + ChannelsStartParamsSchema, ChannelsLogoutParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, @@ -720,6 +725,7 @@ export type { TalkModeParams, ChannelsStatusParams, ChannelsStatusResult, + ChannelsStartParams, ChannelsLogoutParams, WebLoginStartParams, WebLoginWaitParams, diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 8903460da69..2fb1bceea28 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -185,6 +185,14 @@ export const ChannelsLogoutParamsSchema = Type.Object( { additionalProperties: false }, ); +export const ChannelsStartParamsSchema = Type.Object( + { + channel: NonEmptyString, + accountId: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + export const WebLoginStartParamsSchema = Type.Object( { force: Type.Optional(Type.Boolean()), diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 1894276b760..b215d1e9b2b 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -50,6 +50,7 @@ import { ToolsEffectiveResultSchema, } from "./agents-models-skills.js"; import { + ChannelsStartParamsSchema, ChannelsLogoutParamsSchema, TalkConfigParamsSchema, TalkConfigResultSchema, @@ -282,6 +283,7 @@ export const ProtocolSchemas = { TalkSpeakResult: TalkSpeakResultSchema, ChannelsStatusParams: ChannelsStatusParamsSchema, ChannelsStatusResult: ChannelsStatusResultSchema, + ChannelsStartParams: ChannelsStartParamsSchema, ChannelsLogoutParams: ChannelsLogoutParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 55d69c36e02..ffb1c93b064 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -84,6 +84,7 @@ export type TalkSpeakParams = SchemaType<"TalkSpeakParams">; export type TalkSpeakResult = SchemaType<"TalkSpeakResult">; export type ChannelsStatusParams = SchemaType<"ChannelsStatusParams">; export type ChannelsStatusResult = SchemaType<"ChannelsStatusResult">; +export type ChannelsStartParams = SchemaType<"ChannelsStartParams">; export type ChannelsLogoutParams = SchemaType<"ChannelsLogoutParams">; export type WebLoginStartParams = SchemaType<"WebLoginStartParams">; export type WebLoginWaitParams = SchemaType<"WebLoginWaitParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 6b9ba30a06b..4d0772eae62 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -12,6 +12,7 @@ const BASE_METHODS = [ "doctor.memory.dedupeDreamDiary", "logs.tail", "channels.status", + "channels.start", "channels.logout", "status", "usage.status", diff --git a/src/gateway/server-methods/channels.start.test.ts b/src/gateway/server-methods/channels.start.test.ts new file mode 100644 index 00000000000..7f10b21aa5f --- /dev/null +++ b/src/gateway/server-methods/channels.start.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), + applyPluginAutoEnable: vi.fn(), + getChannelPlugin: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + readConfigFileSnapshot: vi.fn(), +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: mocks.applyPluginAutoEnable, +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + listChannelPlugins: vi.fn(), + getChannelPlugin: mocks.getChannelPlugin, + normalizeChannelId: (value: string) => value, +})); + +import { channelsHandlers } from "./channels.js"; + +function createOptions( + params: Record, + overrides?: Partial, +): GatewayRequestHandlerOptions { + return { + req: { type: "req", id: "req-1", method: "channels.start", params }, + params, + client: null, + isWebchatConnect: () => false, + respond: vi.fn(), + context: { + startChannel: vi.fn(), + getRuntimeSnapshot: vi.fn( + (): ChannelRuntimeSnapshot => ({ + channels: { + whatsapp: { + accountId: "default-account", + running: true, + }, + }, + channelAccounts: { + whatsapp: { + "default-account": { + accountId: "default-account", + running: true, + }, + }, + }, + }), + ), + }, + ...overrides, + } as unknown as GatewayRequestHandlerOptions; +} + +describe("channelsHandlers channels.start", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({}); + mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); + mocks.getChannelPlugin.mockReturnValue({ + id: "whatsapp", + gateway: { startAccount: vi.fn() }, + config: { + defaultAccountId: () => "default-account", + listAccountIds: () => ["default-account"], + resolveAccount: () => ({}), + }, + }); + }); + + it("resolves the default account and starts the channel runtime", async () => { + const startChannel = vi.fn(); + const respond = vi.fn(); + + await channelsHandlers["channels.start"]( + createOptions( + { channel: "whatsapp" }, + { + respond, + context: { + startChannel, + getRuntimeSnapshot: vi.fn( + (): ChannelRuntimeSnapshot => ({ + channels: { + whatsapp: { + accountId: "default-account", + running: true, + }, + }, + channelAccounts: { + whatsapp: { + "default-account": { + accountId: "default-account", + running: true, + }, + }, + }, + }), + ), + } as unknown as GatewayRequestHandlerOptions["context"], + }, + ), + ); + + expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({ + config: {}, + env: process.env, + }); + expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account"); + expect(respond).toHaveBeenCalledWith( + true, + { + channel: "whatsapp", + accountId: "default-account", + started: true, + }, + undefined, + ); + }); + + it("reports started=false when the channel runtime remains stopped", async () => { + const startChannel = vi.fn(); + const respond = vi.fn(); + + await channelsHandlers["channels.start"]( + createOptions( + { channel: "whatsapp" }, + { + respond, + context: { + startChannel, + getRuntimeSnapshot: vi.fn( + (): ChannelRuntimeSnapshot => ({ + channels: { + whatsapp: { + accountId: "default-account", + running: false, + }, + }, + channelAccounts: { + whatsapp: { + "default-account": { + accountId: "default-account", + running: false, + }, + }, + }, + }), + ), + } as unknown as GatewayRequestHandlerOptions["context"], + }, + ), + ); + + expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account"); + expect(respond).toHaveBeenCalledWith( + true, + { + channel: "whatsapp", + accountId: "default-account", + started: false, + }, + undefined, + ); + }); +}); diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index c3b60dba0fb..6ef56f1ae7d 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -20,9 +20,11 @@ import { ErrorCodes, errorShape, formatValidationErrors, + validateChannelsStartParams, validateChannelsLogoutParams, validateChannelsStatusParams, } from "../protocol/index.js"; +import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; @@ -33,6 +35,39 @@ type ChannelLogoutPayload = { [key: string]: unknown; }; +type ChannelStartPayload = { + channel: ChannelId; + accountId: string; + started: boolean; +}; + +function resolveRuntimeAccountSnapshot(params: { + runtime: ChannelRuntimeSnapshot; + channelId: ChannelId; + accountId: string; +}): ChannelAccountSnapshot | undefined { + const accounts = params.runtime.channelAccounts[params.channelId]; + const direct = accounts?.[params.accountId]; + if (direct) { + return direct; + } + const fallback = params.runtime.channels[params.channelId]; + return fallback?.accountId === params.accountId ? fallback : undefined; +} + +function resolveChannelGatewayAccountId(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId?: string | null; +}): string { + return ( + normalizeOptionalString(params.accountId) || + params.plugin.config.defaultAccountId?.(params.cfg) || + params.plugin.config.listAccountIds(params.cfg)[0] || + DEFAULT_ACCOUNT_ID + ); +} + export async function logoutChannelAccount(params: { channelId: ChannelId; accountId?: string | null; @@ -40,11 +75,7 @@ export async function logoutChannelAccount(params: { context: GatewayRequestContext; plugin: ChannelPlugin; }): Promise { - const resolvedAccountId = - normalizeOptionalString(params.accountId) || - params.plugin.config.defaultAccountId?.(params.cfg) || - params.plugin.config.listAccountIds(params.cfg)[0] || - DEFAULT_ACCOUNT_ID; + const resolvedAccountId = resolveChannelGatewayAccountId(params); const account = params.plugin.config.resolveAccount(params.cfg, resolvedAccountId); await params.context.stopChannel(params.channelId, resolvedAccountId); const result = await params.plugin.gateway?.logoutAccount?.({ @@ -69,6 +100,32 @@ export async function logoutChannelAccount(params: { }; } +export async function startChannelAccount(params: { + channelId: ChannelId; + accountId?: string | null; + cfg: OpenClawConfig; + context: GatewayRequestContext; + plugin: ChannelPlugin; +}): Promise { + if (!params.plugin.gateway?.startAccount) { + throw new Error(`Channel ${params.channelId} does not support runtime start`); + } + const resolvedAccountId = resolveChannelGatewayAccountId(params); + await params.context.startChannel(params.channelId, resolvedAccountId); + const runtime = params.context.getRuntimeSnapshot(); + const started = + resolveRuntimeAccountSnapshot({ + runtime, + channelId: params.channelId, + accountId: resolvedAccountId, + })?.running === true; + return { + channel: params.channelId, + accountId: resolvedAccountId, + started, + }; +} + export const channelsHandlers: GatewayRequestHandlers = { "channels.status": async ({ params, respond, context }) => { if (!validateChannelsStatusParams(params)) { @@ -240,6 +297,62 @@ export const channelsHandlers: GatewayRequestHandlers = { respond(true, payload, undefined); }, + "channels.start": async ({ params, respond, context }) => { + if (!validateChannelsStartParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid channels.start params: ${formatValidationErrors(validateChannelsStartParams.errors)}`, + ), + ); + return; + } + const rawChannel = (params as { channel?: unknown }).channel; + const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null; + if (!channelId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid channels.start channel"), + ); + return; + } + const plugin = getChannelPlugin(channelId); + if (!plugin) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown channel: ${formatForLog(rawChannel)}`), + ); + return; + } + if (!plugin.gateway?.startAccount) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `channel ${channelId} does not support start`), + ); + return; + } + try { + const cfg = applyPluginAutoEnable({ + config: loadConfig(), + env: process.env, + }).config; + const payload = await startChannelAccount({ + channelId, + accountId: (params as { accountId?: string | null }).accountId, + cfg, + context, + plugin, + }); + respond(true, payload, undefined); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error))); + } + }, "channels.logout": async ({ params, respond, context }) => { if (!validateChannelsLogoutParams(params)) { respond( diff --git a/src/gateway/server-methods/web.start.test.ts b/src/gateway/server-methods/web.start.test.ts new file mode 100644 index 00000000000..da5e45499b8 --- /dev/null +++ b/src/gateway/server-methods/web.start.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + listChannelPlugins: vi.fn(), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + listChannelPlugins: mocks.listChannelPlugins, +})); + +import { webHandlers } from "./web.js"; + +function createOptions( + params: Record, + overrides?: Partial, +): GatewayRequestHandlerOptions { + return { + req: { type: "req", id: "req-1", method: "web.login.start", params }, + params, + client: null, + isWebchatConnect: () => false, + respond: vi.fn(), + context: { + stopChannel: vi.fn(), + startChannel: vi.fn(), + getRuntimeSnapshot: vi.fn( + (): ChannelRuntimeSnapshot => ({ + channels: { + whatsapp: { + accountId: "default", + running: true, + }, + }, + channelAccounts: { + whatsapp: { + default: { + accountId: "default", + running: true, + }, + }, + }, + }), + ), + }, + ...overrides, + } as unknown as GatewayRequestHandlerOptions; +} + +describe("webHandlers web.login.start", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("restarts a previously running channel when login start exits early without a QR", async () => { + const loginWithQrStart = vi.fn().mockResolvedValue({ + code: "whatsapp-auth-unstable", + message: "retry later", + }); + mocks.listChannelPlugins.mockReturnValue([ + { + id: "whatsapp", + gatewayMethods: ["web.login.start"], + gateway: { loginWithQrStart }, + }, + ]); + const startChannel = vi.fn(); + const stopChannel = vi.fn(); + const respond = vi.fn(); + + await webHandlers["web.login.start"]( + createOptions( + { accountId: "default" }, + { + respond, + context: { + stopChannel, + startChannel, + getRuntimeSnapshot: vi.fn( + (): ChannelRuntimeSnapshot => ({ + channels: { + whatsapp: { + accountId: "default", + running: true, + }, + }, + channelAccounts: { + whatsapp: { + default: { + accountId: "default", + running: true, + }, + }, + }, + }), + ), + } as unknown as GatewayRequestHandlerOptions["context"], + }, + ), + ); + + expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default"); + expect(startChannel).toHaveBeenCalledWith("whatsapp", "default"); + expect(respond).toHaveBeenCalledWith( + true, + { + code: "whatsapp-auth-unstable", + message: "retry later", + }, + undefined, + ); + }); + + it("keeps the channel stopped when login start has taken over with a QR flow", async () => { + const loginWithQrStart = vi.fn().mockResolvedValue({ + qrDataUrl: "data:image/png;base64,qr", + message: "scan qr", + }); + mocks.listChannelPlugins.mockReturnValue([ + { + id: "whatsapp", + gatewayMethods: ["web.login.start"], + gateway: { loginWithQrStart }, + }, + ]); + const startChannel = vi.fn(); + const stopChannel = vi.fn(); + + await webHandlers["web.login.start"]( + createOptions( + { accountId: "default" }, + { + context: { + stopChannel, + startChannel, + getRuntimeSnapshot: vi.fn( + (): ChannelRuntimeSnapshot => ({ + channels: { + whatsapp: { + accountId: "default", + running: true, + }, + }, + channelAccounts: { + whatsapp: { + default: { + accountId: "default", + running: true, + }, + }, + }, + }), + ), + } as unknown as GatewayRequestHandlerOptions["context"], + }, + ), + ); + + expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default"); + expect(startChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index 26f6f44ea81..d9128b4bddc 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -1,4 +1,5 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; +import type { ChannelId } from "../../channels/plugins/types.public.js"; import { ErrorCodes, errorShape, @@ -38,6 +39,25 @@ function respondProviderUnsupported(respond: RespondFn, providerId: string) { ); } +function wasChannelRunning(params: { + context: Parameters[0]["context"]; + channelId: ChannelId; + accountId?: string; +}): boolean { + const runtime = params.context.getRuntimeSnapshot(); + if (params.accountId) { + const accountRuntime = runtime.channelAccounts[params.channelId]?.[params.accountId]; + if (accountRuntime) { + return accountRuntime.running === true; + } + } + if (!params.accountId) { + return runtime.channels[params.channelId]?.running === true; + } + const defaultRuntime = runtime.channels[params.channelId]; + return defaultRuntime?.accountId === params.accountId && defaultRuntime.running === true; +} + export const webHandlers: GatewayRequestHandlers = { "web.login.start": async ({ params, respond, context }) => { if (!validateWebLoginStartParams(params)) { @@ -58,11 +78,16 @@ export const webHandlers: GatewayRequestHandlers = { respondProviderUnavailable(respond); return; } - await context.stopChannel(provider.id, accountId); if (!provider.gateway?.loginWithQrStart) { respondProviderUnsupported(respond, provider.id); return; } + const wasRunning = wasChannelRunning({ + context, + channelId: provider.id, + accountId, + }); + await context.stopChannel(provider.id, accountId); const result = await provider.gateway.loginWithQrStart({ force: Boolean((params as { force?: boolean }).force), timeoutMs: @@ -72,6 +97,11 @@ export const webHandlers: GatewayRequestHandlers = { verbose: Boolean((params as { verbose?: boolean }).verbose), accountId, }); + if (result.connected) { + await context.startChannel(provider.id, accountId); + } else if (wasRunning && !result.qrDataUrl) { + await context.startChannel(provider.id, accountId); + } respond(true, result, undefined); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 0a00b6beccb..7542b03c2f2 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -61,6 +61,36 @@ async function expectSharedOperatorScopesCleared( } } +async function expectLocalBackendGatewayClientScopesPreserved( + port: number, + auth: { token?: string; password?: string }, +) { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + ...auth, + client: { ...BACKEND_GATEWAY_CLIENT }, + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(true); + + const helloOk = res.payload as + | { + auth?: { + scopes?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(true); + } finally { + ws.close(); + } +} + describe("gateway auth compatibility baseline", () => { describe("token mode", () => { let server: Awaited>; @@ -94,6 +124,10 @@ describe("gateway auth compatibility baseline", () => { await expectSharedOperatorScopesCleared(port, { token: "secret" }); }); + test("preserves scopes for direct-local backend shared-token connects without device identity", async () => { + await expectLocalBackendGatewayClientScopesPreserved(port, { token: "secret" }); + }); + test("returns stable token-missing details for control ui without token", async () => { const ws = await openWs(port, { origin: originForPort(port) }); try { @@ -262,6 +296,10 @@ describe("gateway auth compatibility baseline", () => { test("clears requested scopes for shared-password operator connects without device identity", async () => { await expectSharedOperatorScopesCleared(port, { password: "secret" }); }); + + test("preserves scopes for direct-local backend shared-password connects without device identity", async () => { + await expectLocalBackendGatewayClientScopesPreserved(port, { password: "secret" }); + }); }); describe("none mode", () => { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index c8ae46f176d..77f0a5a77a2 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -575,6 +575,24 @@ export function attachGatewayWsMessageHandler(params: { connectParams.scopes = scopes; } }; + let pairingLocality = resolvePairingLocality({ + connectParams, + isLocalClient, + requestHost, + requestOrigin, + remoteAddress: remoteAddr, + hasProxyHeaders, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); + let skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: pairingLocality, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); const handleMissingDeviceIdentity = (): boolean => { const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, @@ -600,10 +618,12 @@ export function attachGatewayWsMessageHandler(params: { isLocalClient, }); // Shared token/password auth can bypass pairing for trusted operators. - // Device-less clients only keep self-declared scopes on the explicit - // allow path, including trusted token-authenticated backend operators. + // Device-less clients still clear self-declared scopes by default, with + // one narrow exception: the direct-local backend gateway-client shared- + // auth handoff used for in-process control-plane coordination. if ( !device && + !skipLocalBackendSelfPairing && shouldClearUnboundScopesForMissingDeviceIdentity({ decision, controlUiAuthPolicy, @@ -739,6 +759,24 @@ export function attachGatewayWsMessageHandler(params: { }), verifyDeviceToken, })); + pairingLocality = resolvePairingLocality({ + connectParams, + isLocalClient, + requestHost, + requestOrigin, + remoteAddress: remoteAddr, + hasProxyHeaders, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); + skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: pairingLocality, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); if (!authOk) { rejectUnauthorized(authResult); return; @@ -773,24 +811,6 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, }); - const pairingLocality = resolvePairingLocality({ - connectParams, - isLocalClient, - requestHost, - requestOrigin, - remoteAddress: remoteAddr, - hasProxyHeaders, - hasBrowserOriginHeader, - sharedAuthOk, - authMethod, - }); - const skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({ - connectParams, - locality: pairingLocality, - hasBrowserOriginHeader, - sharedAuthOk, - authMethod, - }); const skipControlUiPairingForDevice = shouldSkipControlUiPairing( controlUiAuthPolicy, role, diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index 94792cd96db..55e7d8cf4b9 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -72,6 +72,7 @@ function makeTelegramSummaryPlugin(params: { enabled: boolean; configured: boolean; linked?: boolean; + statusState?: string; authAgeMs?: number; allowFrom?: string[]; }): ChannelPlugin { @@ -107,6 +108,7 @@ function makeTelegramSummaryPlugin(params: { }, status: { buildChannelSummary: async () => ({ + statusState: params.statusState, linked: params.linked, configured: params.configured, authAgeMs: params.authAgeMs, @@ -279,6 +281,29 @@ describe("buildChannelSummary", () => { expect(lines).toContain(" - primary (Main Bot) (dm:mutuals, token:env)"); }); + it("prefers plugin statusState when provided", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: makeTelegramSummaryPlugin({ + enabled: true, + configured: true, + statusState: "unstable", + }), + source: "test", + }, + ]), + ); + + const lines = await buildChannelSummary({ channels: {} } as never, { + colorize: false, + includeAllowFrom: false, + }); + + expect(lines).toContain("Telegram: auth stabilizing +15551234567"); + }); + it("renders non-slack account detail fields for configured accounts", async () => { setActivePluginRegistry( createTestRegistry([ diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 73d2ed8bc2e..7db119fdfaf 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -9,6 +9,7 @@ import { resolveChannelAccountEnabled, } from "../channels/account-summary.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; +import { formatChannelStatusState } from "../channels/plugins/status-state.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; @@ -199,6 +200,10 @@ export async function buildChannelSummary( : undefined; const summaryRecord = summary; + const statusState = + summaryRecord && typeof summaryRecord.statusState === "string" + ? summaryRecord.statusState + : null; const linked = summaryRecord && typeof summaryRecord.linked === "boolean" ? summaryRecord.linked : null; const configured = @@ -208,18 +213,20 @@ export async function buildChannelSummary( const status = !anyEnabled ? "disabled" - : linked !== null - ? linked - ? "linked" - : "not linked" - : configured - ? "configured" - : "not configured"; + : statusState + ? formatChannelStatusState(statusState) + : linked !== null + ? linked + ? "linked" + : "not linked" + : configured + ? "configured" + : "not configured"; const statusColor = status === "linked" || status === "configured" ? theme.success - : status === "not linked" + : status === "not linked" || status === "auth stabilizing" ? theme.error : theme.muted; const baseLabel = plugin.meta.label ?? plugin.id; diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts index cf4c44ce7d9..650f1389f0f 100644 --- a/ui/src/ui/controllers/channels.ts +++ b/ui/src/ui/controllers/channels.ts @@ -41,16 +41,17 @@ export async function startWhatsAppLogin(state: ChannelsState, force: boolean) { } state.whatsappBusy = true; try { - const res = await state.client.request<{ message?: string; qrDataUrl?: string }>( - "web.login.start", - { - force, - timeoutMs: 30000, - }, - ); + const res = await state.client.request<{ + message?: string; + qrDataUrl?: string; + connected?: boolean; + }>("web.login.start", { + force, + timeoutMs: 30000, + }); state.whatsappLoginMessage = res.message ?? null; state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; - state.whatsappLoginConnected = null; + state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null; } catch (err) { state.whatsappLoginMessage = String(err); state.whatsappLoginQrDataUrl = null;