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
This commit is contained in:
Vincent Koc
2026-04-28 03:24:57 -07:00
committed by GitHub
parent e2f3044b8f
commit 7950a18025
7 changed files with 458 additions and 67 deletions

View File

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

View File

@@ -18,6 +18,7 @@ const hoisted = vi.hoisted(() => ({
waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise<CredsQueueWaitResult>>(
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<T>(
prefix: string,
run: (authDir: string) => Promise<T>,
): Promise<T> {
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 () => {

View File

@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<WebAuthDirOwnership> {
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;

View File

@@ -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,
},
);

View File

@@ -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 () => {

View File

@@ -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<typeof resolveWhatsAppAccount>;
runtime: RuntimeEnv;
statusLabel: number | "unknown";
healthState: "logged-out" | "conflict";
log: ReturnType<typeof getChildLogger>;
}) {
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(

View File

@@ -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);
});
});