diff --git a/extensions/whatsapp/setup-entry.test.ts b/extensions/whatsapp/setup-entry.test.ts index 4c0de0b07469..d0fc62f9fb97 100644 --- a/extensions/whatsapp/setup-entry.test.ts +++ b/extensions/whatsapp/setup-entry.test.ts @@ -1,4 +1,7 @@ // Whatsapp tests cover setup entry plugin behavior. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import * as legacySessionSurfaceApi from "./legacy-session-surface-api.js"; import * as legacyStateMigrationsApi from "./legacy-state-migrations-api.js"; @@ -68,6 +71,57 @@ describe("whatsapp setup entry", () => { expect(legacySessionSurface.isLegacyGroupSessionKey).toBeTypeOf("function"); }); + it("plans migration for every Baileys auth category while preserving other shared-root files", async () => { + const oauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-wa-legacy-migration-")); + const authFiles = [ + "creds.json", + "creds.json.bak", + "pre-key-1.json", + "session-contact.json", + "sender-key-group.json", + "sender-key-memory-group.json", + "app-state-sync-key-contact.json", + "app-state-sync-version-contact.json", + "lid-mapping-15551234567.json", + "device-list-15551234567.json", + "tctoken-15551234567.json", + "identity-key-15551234567.json", + ]; + + try { + for (const file of [...authFiles, "oauth.json", "google-oauth.json", "notes.txt"]) { + fs.writeFileSync(path.join(oauthDir, file), "{}", "utf-8"); + } + fs.mkdirSync(path.join(oauthDir, "nested")); + fs.writeFileSync(path.join(oauthDir, "nested", "session-keep.json"), "{}", "utf-8"); + fs.symlinkSync(path.join(oauthDir, "notes.txt"), path.join(oauthDir, "session-linked.json")); + + const detectLegacyStateMigrations = + setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions); + if (!detectLegacyStateMigrations) { + throw new Error("expected WhatsApp legacy state migration detector"); + } + const migrations = + (await detectLegacyStateMigrations({ + cfg: {}, + env: {}, + oauthDir, + stateDir: oauthDir, + })) ?? []; + + expect(migrations.map((migration) => path.basename(migration.sourcePath)).toSorted()).toEqual( + authFiles.toSorted(), + ); + for (const migration of migrations) { + expect(migration.targetPath).toBe( + path.join(oauthDir, "whatsapp", "default", path.basename(migration.sourcePath)), + ); + } + } finally { + fs.rmSync(oauthDir, { recursive: true, force: true }); + } + }); + it("loads the delegated setup wizard without importing runtime dependencies", async () => { const { whatsappSetupWizard } = await import("./src/setup-surface.js"); diff --git a/extensions/whatsapp/src/auth-store.test.ts b/extensions/whatsapp/src/auth-store.test.ts index c52ca100ef4c..54f54ce87f47 100644 --- a/extensions/whatsapp/src/auth-store.test.ts +++ b/extensions/whatsapp/src/auth-store.test.ts @@ -351,6 +351,56 @@ describe("auth-store", () => { } }); + it("clears every Baileys auth category from the shared legacy root without touching other files", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-legacy-categories"); + const previousOAuthDir = hoisted.oauthDir; + const authFiles = [ + "creds.json", + "creds.json.bak", + "pre-key-1.json", + "session-contact.json", + "sender-key-group.json", + "sender-key-memory-group.json", + "app-state-sync-key-contact.json", + "app-state-sync-version-contact.json", + "lid-mapping-15551234567.json", + "device-list-15551234567.json", + "tctoken-15551234567.json", + "identity-key-15551234567.json", + ]; + const unrelatedFiles = ["oauth.json", "google-oauth.json", "notes.txt"]; + const nestedAuthFile = path.join(authDir, "nested", "session-keep.json"); + hoisted.oauthDir = authDir; + + try { + for (const file of [...authFiles, ...unrelatedFiles]) { + fsSync.writeFileSync(path.join(authDir, file), "{}", "utf-8"); + } + fsSync.mkdirSync(path.dirname(nestedAuthFile)); + fsSync.writeFileSync(nestedAuthFile, "keep", "utf-8"); + fsSync.symlinkSync( + path.join(authDir, "notes.txt"), + path.join(authDir, "session-linked.json"), + ); + + await expect(logoutWeb({ authDir, isLegacyAuthDir: true })).resolves.toBe(true); + + for (const file of authFiles) { + expect(fsSync.existsSync(path.join(authDir, file)), file).toBe(false); + } + for (const file of unrelatedFiles) { + expect(fsSync.existsSync(path.join(authDir, file)), file).toBe(true); + } + expect(fsSync.readFileSync(nestedAuthFile, "utf-8")).toBe("keep"); + expect(fsSync.lstatSync(path.join(authDir, "session-linked.json")).isSymbolicLink()).toBe( + true, + ); + } finally { + hoisted.oauthDir = previousOAuthDir; + fsSync.rmSync(authDir, { recursive: true, force: true }); + } + }); + it("clears auth state even when directory enumeration fails", async () => { await withOwnedOAuthAuthDir("openclaw-wa-auth-readdir", async (authDir) => { fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8"); diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index a268456872c8..a8c67258629a 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -10,6 +10,7 @@ import { resolveOAuthDir } from "./auth-store.runtime.js"; import { assertWebCredsPathRegularFileOrMissing, hasWebCredsSync, + isWhatsAppBaileysAuthFileName, readWebCredsJsonRaw, readWebCredsJsonRawSync, resolveWebCredsBackupPath, @@ -225,19 +226,6 @@ export async function readWebAuthSnapshotBestEffort(authDir: string = resolveDef } as const; } -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, beforeCredentialPersistence?: () => Promise, @@ -248,7 +236,7 @@ async function clearBaileysAuthFiles( } const entries = await fs.readdir(authDir, { withFileTypes: true }); const credentialFiles = entries.filter( - (entry) => entry.isFile() && isBaileysAuthFileName(entry.name), + (entry) => entry.isFile() && isWhatsAppBaileysAuthFileName(entry.name), ); if (credentialFiles.length === 0) { return; @@ -273,7 +261,7 @@ async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): P if (!entry.isFile()) { return false; } - return isBaileysAuthFileName(entry.name); + return isWhatsAppBaileysAuthFileName(entry.name); }); } const credsStats = await fs.lstat(resolveWebCredsPath(authDir)).catch(() => null); diff --git a/extensions/whatsapp/src/creds-files.ts b/extensions/whatsapp/src/creds-files.ts index cf41011a450c..bc6e555dc838 100644 --- a/extensions/whatsapp/src/creds-files.ts +++ b/extensions/whatsapp/src/creds-files.ts @@ -1,5 +1,6 @@ // Whatsapp plugin module implements creds files behavior. import path from "node:path"; +import type { SignalDataTypeMap } from "baileys"; import { assertNoSymlinkParents, assertNoSymlinkParentsSync, @@ -9,6 +10,31 @@ import { statRegularFileSync, } from "openclaw/plugin-sdk/security-runtime"; +// The legacy OAuth root is shared; keep its exact WhatsApp namespaces aligned +// with Baileys without importing the provider into setup discovery. +const BAILEYS_SIGNAL_AUTH_CATEGORIES = { + "app-state-sync-key": true, + "app-state-sync-version": true, + "device-list": true, + "identity-key": true, + "lid-mapping": true, + "pre-key": true, + "sender-key": true, + "sender-key-memory": true, + session: true, + tctoken: true, +} satisfies Record; + +export function isWhatsAppBaileysAuthFileName(name: string): boolean { + if (name === "creds.json" || name === "creds.json.bak") { + return true; + } + return ( + name.endsWith(".json") && + Object.keys(BAILEYS_SIGNAL_AUTH_CATEGORIES).some((category) => name.startsWith(`${category}-`)) + ); +} + export function resolveWebCredsPath(authDir: string): string { return path.join(authDir, "creds.json"); } diff --git a/extensions/whatsapp/src/state-migrations.ts b/extensions/whatsapp/src/state-migrations.ts index deaf2043b0cd..93a2cd183277 100644 --- a/extensions/whatsapp/src/state-migrations.ts +++ b/extensions/whatsapp/src/state-migrations.ts @@ -4,16 +4,7 @@ import path from "node:path"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; import { fileExists } from "openclaw/plugin-sdk/security-runtime"; - -function isLegacyWhatsAppAuthFile(name: string): boolean { - 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); -} +import { isWhatsAppBaileysAuthFileName } from "./creds-files.js"; export function detectWhatsAppLegacyStateMigrations(params: { oauthDir: string; @@ -28,7 +19,7 @@ export function detectWhatsAppLegacyStateMigrations(params: { })(); return entries.flatMap((entry) => { - if (!entry.isFile() || entry.name === "oauth.json" || !isLegacyWhatsAppAuthFile(entry.name)) { + if (!entry.isFile() || !isWhatsAppBaileysAuthFileName(entry.name)) { return []; } const sourcePath = path.join(params.oauthDir, entry.name);