From 7950a18025108602bcf13d3bab7d1e66d60e6112 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 28 Apr 2026 03:24:57 -0700 Subject: [PATCH] fix(whatsapp): recover stale listener after auth conflict churn (#72621) * fix(whatsapp): recover stale listener after auth conflict churn * fix(whatsapp): block symlink auth cleanup escapes * fix(whatsapp): refuse external auth cleanup --- CHANGELOG.md | 1 + extensions/whatsapp/src/auth-store.test.ts | 162 +++++++++++++++--- extensions/whatsapp/src/auth-store.ts | 134 +++++++++++---- .../whatsapp/src/auto-reply.test-harness.ts | 5 + ...o-reply.connection-and-logging.e2e.test.ts | 88 +++++++++- extensions/whatsapp/src/auto-reply/monitor.ts | 59 ++++++- extensions/whatsapp/src/logout.test.ts | 76 +++++++- 7 files changed, 458 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03049943ec8..5bd7d65da9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -437,6 +437,7 @@ Docs: https://docs.openclaw.ai - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. - Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon. - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. +- WhatsApp: clear cached Web auth and active listener state after terminal 440/401 conflict/logout closes so linked/OK status no longer masks a dead inbound listener after relink or restart. Fixes #45474; refs #49305, #63855, #66920, and #70856. Thanks @juvenalmakoszay and @dsantoreis. - Gateway/restart: keep local restart-health probes on configured local daemon auth without falling back to remote gateway credentials. (#57374, #59439) Thanks @zssggle-rgb and @roytong9. - Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain. - Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc. diff --git a/extensions/whatsapp/src/auth-store.test.ts b/extensions/whatsapp/src/auth-store.test.ts index 42df0b6c5b4..e6f5324afd0 100644 --- a/extensions/whatsapp/src/auth-store.test.ts +++ b/extensions/whatsapp/src/auth-store.test.ts @@ -18,6 +18,7 @@ const hoisted = vi.hoisted(() => ({ waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise>( async () => "drained", ), + oauthDir: "/tmp/openclaw-wa-auth-store-test-oauth", })); vi.mock("./creds-persistence.js", async () => { @@ -29,12 +30,31 @@ vi.mock("./creds-persistence.js", async () => { }; }); +vi.mock("./auth-store.runtime.js", () => ({ + resolveOAuthDir: () => hoisted.oauthDir, +})); + function createTempAuthDir(prefix: string) { return fsSync.mkdtempSync( path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`), ); } +function withOwnedOAuthAuthDir( + prefix: string, + run: (authDir: string) => Promise, +): Promise { + const previousOAuthDir = hoisted.oauthDir; + const oauthDir = createTempAuthDir(`${prefix}-oauth`); + const authDir = path.join(oauthDir, "whatsapp", "default"); + fsSync.mkdirSync(authDir, { recursive: true }); + hoisted.oauthDir = oauthDir; + return run(authDir).finally(() => { + hoisted.oauthDir = previousOAuthDir; + fsSync.rmSync(oauthDir, { recursive: true, force: true }); + }); +} + describe("auth-store", () => { beforeEach(() => { hoisted.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue("drained"); @@ -115,29 +135,32 @@ describe("auth-store", () => { }); 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", - ); + await withOwnedOAuthAuthDir("openclaw-wa-auth-logout", async (authDir) => { + 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(), - }; + 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); + 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"); + const previousOAuthDir = hoisted.oauthDir; 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"); + hoisted.oauthDir = authDir; const originalRm = fs.rm; const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => { if (String(target).endsWith("creds.json")) { @@ -151,29 +174,114 @@ describe("auth-store", () => { 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(); + try { + 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); + } finally { + hoisted.oauthDir = previousOAuthDir; + rmSpy.mockRestore(); + fsSync.rmSync(authDir, { recursive: true, force: true }); + } }); it("clears auth state even when directory enumeration fails", async () => { - const authDir = createTempAuthDir("openclaw-wa-auth-readdir"); + await withOwnedOAuthAuthDir("openclaw-wa-auth-readdir", async (authDir) => { + 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 custom auth directories outside the OpenClaw auth root", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-custom"); + const nestedDir = path.join(authDir, "nested"); + fsSync.mkdirSync(nestedDir); fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8"); - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })); + fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8"); + fsSync.writeFileSync(path.join(nestedDir, "session-abc.json"), "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(true); - expect(fsSync.existsSync(authDir)).toBe(false); - readdirSpy.mockRestore(); + await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false); + expect(fsSync.existsSync(authDir)).toBe(true); + expect(fsSync.existsSync(path.join(authDir, "creds.json"))).toBe(true); + expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true); + expect(fsSync.existsSync(path.join(nestedDir, "session-abc.json"))).toBe(true); + }); + + it("does not clear auth files through a symlinked owned auth directory", async () => { + const previousOAuthDir = hoisted.oauthDir; + const oauthDir = createTempAuthDir("openclaw-wa-auth-symlink-oauth"); + const externalDir = createTempAuthDir("openclaw-wa-auth-symlink-target"); + const authDir = path.join(oauthDir, "whatsapp", "default"); + try { + fsSync.mkdirSync(path.dirname(authDir), { recursive: true }); + fsSync.writeFileSync(path.join(externalDir, "creds.json"), "{}", "utf-8"); + fsSync.writeFileSync(path.join(externalDir, "notes.txt"), "keep me", "utf-8"); + fsSync.symlinkSync(externalDir, authDir, "dir"); + hoisted.oauthDir = oauthDir; + 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(externalDir, "creds.json"))).toBe(true); + expect(fsSync.existsSync(path.join(externalDir, "notes.txt"))).toBe(true); + } finally { + hoisted.oauthDir = previousOAuthDir; + fsSync.rmSync(oauthDir, { recursive: true, force: true }); + fsSync.rmSync(externalDir, { recursive: true, force: true }); + } + }); + + it("does not clear auth files through an intermediate symlink in the owned auth tree", async () => { + const previousOAuthDir = hoisted.oauthDir; + const oauthDir = createTempAuthDir("openclaw-wa-auth-symlink-parent-oauth"); + const externalRoot = createTempAuthDir("openclaw-wa-auth-symlink-parent-target"); + const externalAuthDir = path.join(externalRoot, "default"); + const linkedParent = path.join(oauthDir, "whatsapp", "linked"); + const authDir = path.join(linkedParent, "default"); + try { + fsSync.mkdirSync(path.dirname(linkedParent), { recursive: true }); + fsSync.mkdirSync(externalAuthDir, { recursive: true }); + fsSync.writeFileSync(path.join(externalAuthDir, "creds.json"), "{}", "utf-8"); + fsSync.writeFileSync(path.join(externalAuthDir, "notes.txt"), "keep me", "utf-8"); + fsSync.symlinkSync(externalRoot, linkedParent, "dir"); + hoisted.oauthDir = oauthDir; + 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(externalAuthDir, "creds.json"))).toBe(true); + expect(fsSync.existsSync(path.join(externalAuthDir, "notes.txt"))).toBe(true); + } finally { + hoisted.oauthDir = previousOAuthDir; + fsSync.rmSync(oauthDir, { recursive: true, force: true }); + fsSync.rmSync(externalRoot, { recursive: true, force: true }); + } }); it("does not delete unrelated non-empty directories on logout", async () => { diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index f4f111aa9e6..77c676bb5b5 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -220,26 +220,31 @@ export async function readWebAuthSnapshotBestEffort(authDir: string = resolveDef } as const; } -async function clearLegacyBaileysAuthState(authDir: string) { +function isBaileysAuthFileName(name: string): boolean { + if (name === "oauth.json") { + return false; + } + if (name === "creds.json" || name === "creds.json.bak") { + return true; + } + if (!name.endsWith(".json")) { + return false; + } + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); +} + +async function clearBaileysAuthFiles(authDir: string) { + const rootStats = await fs.lstat(authDir).catch(() => null); + if (!rootStats?.isDirectory() || rootStats.isSymbolicLink()) { + return; + } const entries = await fs.readdir(authDir, { withFileTypes: true }); - const shouldDelete = (name: string) => { - if (name === "oauth.json") { - return false; - } - if (name === "creds.json" || name === "creds.json.bak") { - return true; - } - if (!name.endsWith(".json")) { - return false; - } - return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); - }; await Promise.all( entries.map(async (entry) => { if (!entry.isFile()) { return; } - if (!shouldDelete(entry.name)) { + if (!isBaileysAuthFileName(entry.name)) { return; } await fs.rm(path.join(authDir, entry.name), { force: true }); @@ -249,9 +254,9 @@ 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; + const stats = await fs.lstat(authDir); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return false; } if (isLegacyAuthDir) { const entries = await fs.readdir(authDir, { withFileTypes: true }); @@ -259,22 +264,14 @@ async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): P 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; + return isBaileysAuthFileName(entry.name); }); } - const credsStats = await fs.stat(resolveWebCredsPath(authDir)).catch(() => null); + const credsStats = await fs.lstat(resolveWebCredsPath(authDir)).catch(() => null); if (credsStats?.isFile()) { return true; } - const backupStats = await fs.stat(resolveWebCredsBackupPath(authDir)).catch(() => null); + const backupStats = await fs.lstat(resolveWebCredsBackupPath(authDir)).catch(() => null); return backupStats?.isFile() === true; } catch (error) { const codeValue = @@ -286,6 +283,62 @@ async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): P } } +function isPathInsideDirectory(baseDir: string, targetPath: string): boolean { + const relativePath = path.relative(baseDir, targetPath); + return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +} + +async function pathHasSymlinkComponent(baseDir: string, targetPath: string): Promise { + const relativePath = path.relative(baseDir, targetPath); + let currentPath = baseDir; + for (const segment of relativePath.split(path.sep)) { + currentPath = path.join(currentPath, segment); + const stats = await fs.lstat(currentPath).catch(() => null); + if (!stats || stats.isSymbolicLink()) { + return true; + } + } + return false; +} + +type WebAuthDirOwnership = + | { kind: "owned"; authDir: string } + | { kind: "unsafe-owned" } + | { kind: "external" }; + +async function isLegacyWebAuthDir(authDir: string): Promise { + const legacyAuthDir = path.resolve(resolveOAuthDir()); + const resolvedAuthDir = path.resolve(authDir); + if (resolvedAuthDir !== legacyAuthDir) { + return false; + } + const stats = await fs.lstat(resolvedAuthDir).catch(() => null); + return stats?.isDirectory() === true && !stats.isSymbolicLink(); +} + +async function classifyWebAuthDirOwnership(authDir: string): Promise { + const whatsappAuthBase = path.resolve(resolveOAuthDir(), "whatsapp"); + const resolvedAuthDir = path.resolve(authDir); + if (!isPathInsideDirectory(whatsappAuthBase, resolvedAuthDir)) { + return { kind: "external" }; + } + + const [baseRealPath, authDirRealPath] = await Promise.all([ + fs.realpath(whatsappAuthBase).catch(() => null), + fs.realpath(resolvedAuthDir).catch(() => null), + ]); + if (!baseRealPath || !authDirRealPath) { + return { kind: "unsafe-owned" }; + } + if (!isPathInsideDirectory(baseRealPath, authDirRealPath)) { + return { kind: "unsafe-owned" }; + } + if (await pathHasSymlinkComponent(whatsappAuthBase, resolvedAuthDir)) { + return { kind: "unsafe-owned" }; + } + return { kind: "owned", authDir: resolvedAuthDir }; +} + export async function logoutWeb(params: { authDir?: string; isLegacyAuthDir?: boolean; @@ -304,9 +357,30 @@ export async function logoutWeb(params: { return false; } if (params.isLegacyAuthDir) { - await clearLegacyBaileysAuthState(resolvedAuthDir); + if (!(await isLegacyWebAuthDir(resolvedAuthDir))) { + runtime.log( + info("Skipped WhatsApp Web credential cleanup outside the managed legacy auth directory."), + ); + return false; + } + await clearBaileysAuthFiles(resolvedAuthDir); } else { - await fs.rm(resolvedAuthDir, { recursive: true, force: true }); + const ownership = await classifyWebAuthDirOwnership(resolvedAuthDir); + if (ownership.kind === "owned") { + await fs.rm(ownership.authDir, { recursive: true, force: true }); + } else if (ownership.kind === "unsafe-owned") { + runtime.log( + info( + "Skipped WhatsApp Web credential cleanup because the auth directory crosses a symlink boundary.", + ), + ); + return false; + } else { + runtime.log( + info("Skipped WhatsApp Web credential cleanup outside the managed auth directory."), + ); + return false; + } } runtime.log(success("Cleared WhatsApp Web credentials.")); return true; diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index d0f0c7db76c..d6d0da90f43 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -7,6 +7,7 @@ import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-dedupe"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest"; +import type { WebChannelStatus } from "./auto-reply/types.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js"; import { resetBaileysMocks as _resetBaileysMocks, @@ -311,6 +312,8 @@ export function startWebAutoReplyMonitor(params: { messageTimeoutMs?: number; watchdogCheckMs?: number; reconnect?: { initialMs: number; maxMs: number; maxAttempts: number; factor: number }; + accountId?: string; + statusSink?: (status: WebChannelStatus) => void; }): WebAutoReplyMonitorHarness { const runtime = createWebAutoReplyRuntime(); const controller = new AbortController(); @@ -327,6 +330,8 @@ export function startWebAutoReplyMonitor(params: { watchdogCheckMs: params.watchdogCheckMs, reconnect: params.reconnect ?? { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, sleep: params.sleep, + accountId: params.accountId, + statusSink: params.statusSink, }, ); 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 f37441ae1e8..94c6e362e7f 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 @@ -1,12 +1,15 @@ import "./test-helpers.js"; import crypto from "node:crypto"; import fs from "node:fs/promises"; +import path from "node:path"; import { escapeRegExp, formatEnvelopeTimestamp } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { beforeAll, describe, expect, it, vi } from "vitest"; -import { WhatsAppAuthUnstableError } from "./auth-store.js"; +import { getActiveWebListener } from "./active-listener.js"; +import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js"; +import { resolveOAuthDir } from "./auth-store.runtime.js"; import { createWebInboundDeliverySpies, createMockWebListener, @@ -178,6 +181,89 @@ describe("web auto-reply connection", () => { expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring")); }); + it.each([ + { + status: 440, + isLoggedOut: false, + healthState: "conflict", + error: "Unknown Stream Errored (conflict)", + }, + { + status: 401, + isLoggedOut: true, + healthState: "logged-out", + error: "Stream Errored (logged out)", + }, + ] as const)( + "clears stale auth and active listener after terminal status $status", + async ({ status, isLoggedOut, healthState, error }) => { + const accountId = `terminal-${status}`; + const authDir = path.join(resolveOAuthDir(), "whatsapp", accountId); + await fs.mkdir(authDir, { recursive: true }); + await fs.writeFile( + resolveWebCredsPath(authDir), + JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), + ); + setLoadConfigMock({ + channels: { + whatsapp: { + allowFrom: ["*"], + accounts: { + [accountId]: { + authDir, + }, + }, + }, + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); + + const sleep = vi.fn(async () => {}); + const statuses: Array<{ healthState?: string; running?: boolean; connected?: boolean }> = []; + const scripted = createScriptedWebListenerFactory(); + const { run } = startWebAutoReplyMonitor({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory: scripted.listenerFactory, + sleep, + accountId, + statusSink: (next) => statuses.push(next), + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + }); + + await vi.waitFor( + () => { + expect(scripted.getListenerCount()).toBe(1); + expect(getActiveWebListener(accountId)).not.toBeNull(); + }, + { timeout: 250, interval: 2 }, + ); + + scripted.resolveClose(0, { status, isLoggedOut, error }); + await run; + + expect(scripted.getListenerCount()).toBe(1); + expect(sleep).not.toHaveBeenCalled(); + expect(getActiveWebListener(accountId)).toBeNull(); + await expect(fs.stat(authDir)).rejects.toMatchObject({ code: "ENOENT" }); + expect(statuses).toContainEqual( + expect.objectContaining({ + connected: false, + healthState, + }), + ); + expect(statuses.at(-1)).toEqual( + expect.objectContaining({ + running: false, + connected: false, + healthState, + }), + ); + }, + ); + it("retries inbox attach when auth state is still stabilizing", async () => { const sleep = vi.fn(async () => {}); const listenerFactory = vi.fn(async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 2811b8c3a41..912c0bf74ed 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -27,7 +27,7 @@ import { resolveReconnectPolicy, sleepWithAbort, } from "../reconnect.js"; -import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js"; +import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js"; import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; import { buildMentionConfig } from "./mentions.js"; @@ -96,6 +96,44 @@ function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnst ); } +async function clearTerminalWebAuthState(params: { + account: ReturnType; + runtime: RuntimeEnv; + statusLabel: number | "unknown"; + healthState: "logged-out" | "conflict"; + log: ReturnType; +}) { + try { + const cleared = await logoutWeb({ + authDir: params.account.authDir, + isLegacyAuthDir: params.account.isLegacyAuthDir, + runtime: params.runtime, + }); + params.log.warn( + { + accountId: params.account.accountId, + cleared, + healthState: params.healthState, + status: params.statusLabel, + }, + "web reconnect: cleared cached auth after terminal close", + ); + } catch (error) { + params.log.warn( + { + accountId: params.account.accountId, + error: formatError(error), + healthState: params.healthState, + status: params.statusLabel, + }, + "web reconnect: failed clearing cached auth after terminal close", + ); + params.runtime.error( + `WhatsApp Web cleanup failed after terminal close (status ${params.statusLabel}). Run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\`, then relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`.`, + ); + } +} + export async function monitorWebChannel( verbose: boolean, listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket, @@ -459,6 +497,7 @@ export async function monitorWebChannel( ); if (decision.action === "stop") { + await controller.closeCurrentConnection(); statusController.noteClose({ statusCode: decision.normalized.statusCode, loggedOut: decision.normalized.isLoggedOut, @@ -468,10 +507,24 @@ export async function monitorWebChannel( }); if (decision.healthState === "logged-out") { + await clearTerminalWebAuthState({ + account, + runtime, + statusLabel: decision.normalized.statusLabel, + healthState: decision.healthState, + log: reconnectLogger, + }); runtime.error( - `WhatsApp session logged out. Run \`${formatCliCommand("openclaw channels login --channel web")}\` to relink.`, + `WhatsApp session logged out. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`, ); } else if (decision.healthState === "conflict") { + await clearTerminalWebAuthState({ + account, + runtime, + statusLabel: decision.normalized.statusLabel, + healthState: decision.healthState, + log: reconnectLogger, + }); reconnectLogger.warn( { connectionId: connection.connectionId, @@ -481,7 +534,7 @@ export async function monitorWebChannel( "web reconnect: non-retryable close status; stopping monitor", ); runtime.error( - `WhatsApp Web connection closed (status ${decision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel web")}\`. Stopping web monitoring.`, + `WhatsApp Web connection closed (status ${decision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`, ); } else { reconnectLogger.warn( diff --git a/extensions/whatsapp/src/logout.test.ts b/extensions/whatsapp/src/logout.test.ts index dd042e205da..6b78c2a3aaa 100644 --- a/extensions/whatsapp/src/logout.test.ts +++ b/extensions/whatsapp/src/logout.test.ts @@ -13,20 +13,34 @@ const WEB_LOGOUT_TEST_TIMEOUT_MS = 15_000; describe("web logout", () => { let fixtureRoot = ""; + let previousOAuthDir: string | undefined; let caseId = 0; let logoutWeb: typeof import("./auth-store.js").logoutWeb; beforeAll(async () => { fixtureRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-test-web-logout-")); + previousOAuthDir = process.env.OPENCLAW_OAUTH_DIR; + process.env.OPENCLAW_OAUTH_DIR = path.join(fixtureRoot, "oauth"); ({ logoutWeb } = await import("./auth-store.js")); }); afterAll(async () => { + if (previousOAuthDir === undefined) { + delete process.env.OPENCLAW_OAUTH_DIR; + } else { + process.env.OPENCLAW_OAUTH_DIR = previousOAuthDir; + } await fsPromises.rm(fixtureRoot, { recursive: true, force: true }); }); const makeCaseDir = async () => { - const dir = path.join(fixtureRoot, `case-${caseId++}`); + const dir = path.join(fixtureRoot, "oauth", "whatsapp", `case-${caseId++}`); + await fsPromises.mkdir(dir, { recursive: true }); + return dir; + }; + + const makeExternalCaseDir = async () => { + const dir = path.join(fixtureRoot, "external", `case-${caseId++}`); await fsPromises.mkdir(dir, { recursive: true }); return dir; }; @@ -79,11 +93,11 @@ describe("web logout", () => { }); it("keeps shared oauth.json when using legacy auth dir", async () => { - const credsDir = await createAuthCase({ - "creds.json": "{}", - "oauth.json": '{"token":true}', - "session-abc.json": "{}", - }); + const credsDir = path.join(fixtureRoot, "oauth"); + await fsPromises.mkdir(credsDir, { recursive: true }); + await fsPromises.writeFile(path.join(credsDir, "creds.json"), "{}", "utf-8"); + await fsPromises.writeFile(path.join(credsDir, "oauth.json"), '{"token":true}', "utf-8"); + await fsPromises.writeFile(path.join(credsDir, "session-abc.json"), "{}", "utf-8"); const result = await logoutWeb({ authDir: credsDir, @@ -95,4 +109,54 @@ describe("web logout", () => { expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe(false); }); + + it("does not delete custom auth directories outside the OpenClaw auth root", async () => { + const authDir = await makeExternalCaseDir(); + await fsPromises.mkdir(path.join(authDir, "nested")); + await fsPromises.writeFile(path.join(authDir, "creds.json"), "{}", "utf-8"); + await fsPromises.writeFile(path.join(authDir, "oauth.json"), '{"token":true}', "utf-8"); + await fsPromises.writeFile(path.join(authDir, "notes.txt"), "keep", "utf-8"); + await fsPromises.writeFile(path.join(authDir, "nested", "session-abc.json"), "keep", "utf-8"); + + const result = await logoutWeb({ authDir, runtime: runtime as never }); + expect(result).toBe(false); + expect(fs.existsSync(authDir)).toBe(true); + expect(fs.existsSync(path.join(authDir, "creds.json"))).toBe(true); + expect(fs.existsSync(path.join(authDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(authDir, "notes.txt"))).toBe(true); + expect(fs.existsSync(path.join(authDir, "nested", "session-abc.json"))).toBe(true); + }); + + it("does not delete through symlinked auth dirs inside the OpenClaw auth root", async () => { + const externalDir = await makeExternalCaseDir(); + const authDir = path.join(fixtureRoot, "oauth", "whatsapp", `case-${caseId++}`); + await fsPromises.mkdir(path.dirname(authDir), { recursive: true }); + await fsPromises.writeFile(path.join(externalDir, "creds.json"), "{}", "utf-8"); + await fsPromises.writeFile(path.join(externalDir, "notes.txt"), "keep", "utf-8"); + await fsPromises.symlink(externalDir, authDir, "dir"); + + const result = await logoutWeb({ authDir, runtime: runtime as never }); + expect(result).toBe(false); + expect(fs.existsSync(authDir)).toBe(true); + expect(fs.existsSync(path.join(externalDir, "creds.json"))).toBe(true); + expect(fs.existsSync(path.join(externalDir, "notes.txt"))).toBe(true); + }); + + it("does not delete through intermediate symlinks inside the OpenClaw auth root", async () => { + const externalRoot = path.join(fixtureRoot, "external", `case-${caseId++}`); + const externalAuthDir = path.join(externalRoot, "default"); + const linkedParent = path.join(fixtureRoot, "oauth", "whatsapp", `linked-${caseId++}`); + const authDir = path.join(linkedParent, "default"); + await fsPromises.mkdir(externalAuthDir, { recursive: true }); + await fsPromises.mkdir(path.dirname(linkedParent), { recursive: true }); + await fsPromises.writeFile(path.join(externalAuthDir, "creds.json"), "{}", "utf-8"); + await fsPromises.writeFile(path.join(externalAuthDir, "notes.txt"), "keep", "utf-8"); + await fsPromises.symlink(externalRoot, linkedParent, "dir"); + + const result = await logoutWeb({ authDir, runtime: runtime as never }); + expect(result).toBe(false); + expect(fs.existsSync(authDir)).toBe(true); + expect(fs.existsSync(path.join(externalAuthDir, "creds.json"))).toBe(true); + expect(fs.existsSync(path.join(externalAuthDir, "notes.txt"))).toBe(true); + }); });