mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user