fix(whatsapp): clear and migrate every Baileys credential class (#118610)

* fix(whatsapp): clear and migrate every auth credential class

* test(whatsapp): await nullable legacy migration detector
This commit is contained in:
Peter Steinberger
2026-08-03 03:54:41 -07:00
committed by GitHub
parent 0dbf9b6fae
commit fe6fa891ea
5 changed files with 135 additions and 26 deletions
+54
View File
@@ -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");
@@ -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");
+3 -15
View File
@@ -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<void>,
@@ -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);
+26
View File
@@ -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<keyof SignalDataTypeMap, true>;
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");
}
+2 -11
View File
@@ -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);