mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 18:36:38 +00:00
fix: reject symlinked whatsapp creds
This commit is contained in:
committed by
Peter Steinberger
parent
8284c035a0
commit
194f0786d4
@@ -371,6 +371,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
|
||||
- Agents/tools: keep the `message` tool available in embedded runs when it is explicitly allowed through `tools.alsoAllow` or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.
|
||||
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
|
||||
- WhatsApp: reject symlinked Web credential files across auth checks and socket startup so unsafe `creds.json` paths cannot be read through. Thanks @mcaxtr.
|
||||
- WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr.
|
||||
- Process/diagnostics: report active lane blockers in lane wait warnings so `queueAhead=0` no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.
|
||||
- Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
} from "./account-ids.js";
|
||||
import type { WhatsAppAccountConfig } from "./account-types.js";
|
||||
import { hasWebCredsSync } from "./creds-files.js";
|
||||
import { hasWebCredsRegularFileSync, hasWebCredsSync } from "./creds-files.js";
|
||||
|
||||
export { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId } from "./account-ids.js";
|
||||
|
||||
@@ -88,11 +88,7 @@ function resolveLegacyAuthDir(): string {
|
||||
}
|
||||
|
||||
function legacyAuthExists(authDir: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(path.join(authDir, "creds.json"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return hasWebCredsRegularFileSync(authDir);
|
||||
}
|
||||
|
||||
export function resolveWhatsAppAuthDir(params: { cfg: OpenClawConfig; accountId: string }): {
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { captureEnv } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js";
|
||||
import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs, resolveWhatsAppAuthDir } from "./accounts.js";
|
||||
|
||||
describe("hasAnyWhatsAppAuth", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
@@ -37,6 +37,40 @@ describe("hasAnyWhatsAppAuth", () => {
|
||||
expect(hasAnyWhatsAppAuth({})).toBe(true);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("ignores symlinked legacy creds", () => {
|
||||
const targetPath = path.join(tempOauthDir ?? "", "target-creds.json");
|
||||
const credsPath = path.join(tempOauthDir ?? "", "creds.json");
|
||||
fs.writeFileSync(targetPath, JSON.stringify({ me: {} }));
|
||||
fs.symlinkSync(targetPath, credsPath);
|
||||
|
||||
expect(hasAnyWhatsAppAuth({})).toBe(false);
|
||||
expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({
|
||||
authDir: path.join(tempOauthDir ?? "", "whatsapp", "default"),
|
||||
isLegacy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("selects legacy auth when legacy creds are truncated so backup recovery can run", () => {
|
||||
fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), "{");
|
||||
|
||||
expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({
|
||||
authDir: tempOauthDir,
|
||||
isLegacy: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back to legacy auth when default creds are truncated", () => {
|
||||
const defaultAuthDir = path.join(tempOauthDir ?? "", "whatsapp", "default");
|
||||
fs.mkdirSync(defaultAuthDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), JSON.stringify({ me: {} }));
|
||||
fs.writeFileSync(path.join(defaultAuthDir, "creds.json"), "{");
|
||||
|
||||
expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({
|
||||
authDir: defaultAuthDir,
|
||||
isLegacy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true when non-default auth exists", () => {
|
||||
writeCreds(path.join(tempOauthDir ?? "", "whatsapp", "work"));
|
||||
expect(hasAnyWhatsAppAuth({})).toBe(true);
|
||||
|
||||
@@ -3,10 +3,15 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
hasWebCredsSync,
|
||||
logoutWeb,
|
||||
pickWebChannel,
|
||||
readCredsJsonRaw,
|
||||
readWebAuthSnapshot,
|
||||
readWebAuthState,
|
||||
readWebSelfId,
|
||||
readWebSelfIdentity,
|
||||
restoreCredsFromBackupIfNeeded,
|
||||
webAuthExists,
|
||||
WhatsAppAuthUnstableError,
|
||||
@@ -86,6 +91,29 @@ describe("auth-store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves valid large creds instead of treating them as corrupt", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-large-creds");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
const largeCreds = JSON.stringify({
|
||||
me: { id: "15551234567@s.whatsapp.net" },
|
||||
additionalData: "x".repeat(1024 * 1024 + 512),
|
||||
});
|
||||
fsSync.writeFileSync(credsPath, largeCreds, "utf-8");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json.bak"),
|
||||
JSON.stringify({ me: { id: "19990000000@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(webAuthExists(authDir)).resolves.toBe(true);
|
||||
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
|
||||
expect(fsSync.readFileSync(credsPath, "utf-8")).toBe(largeCreds);
|
||||
expect(readWebSelfId(authDir)).toMatchObject({
|
||||
e164: "+15551234567",
|
||||
jid: "15551234567@s.whatsapp.net",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to restore creds from a symlinked backup path", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-restore-symlink");
|
||||
const targetPath = path.join(authDir, "backup-target.json");
|
||||
@@ -99,6 +127,27 @@ describe("auth-store", () => {
|
||||
expect(fsSync.readFileSync(credsPath, "utf-8")).toBe("{");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not restore backup over a symlinked creds path",
|
||||
async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-restore-target-symlink");
|
||||
const targetPath = path.join(authDir, "target-creds.json");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
const backupPath = path.join(authDir, "creds.json.bak");
|
||||
fsSync.writeFileSync(targetPath, "{", "utf-8");
|
||||
fsSync.symlinkSync(targetPath, credsPath);
|
||||
fsSync.writeFileSync(
|
||||
backupPath,
|
||||
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
|
||||
expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true);
|
||||
expect(fsSync.readFileSync(targetPath, "utf-8")).toBe("{");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports linked auth state and snapshot from the shared read helper", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-linked");
|
||||
fsSync.writeFileSync(
|
||||
@@ -122,6 +171,64 @@ describe("auth-store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"treats symlinked creds as missing across auth readers",
|
||||
async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-symlink-read");
|
||||
const targetPath = path.join(authDir, "target-creds.json");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
fsSync.writeFileSync(
|
||||
targetPath,
|
||||
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
fsSync.symlinkSync(targetPath, credsPath);
|
||||
|
||||
expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true);
|
||||
expect(fsSync.statSync(credsPath).isFile()).toBe(true);
|
||||
expect(hasWebCredsSync(authDir)).toBe(false);
|
||||
expect(readCredsJsonRaw(credsPath)).toBeNull();
|
||||
expect(getWebAuthAgeMs(authDir)).toBeNull();
|
||||
expect(readWebSelfId(authDir)).toEqual({ e164: null, jid: null, lid: null });
|
||||
await expect(readWebSelfIdentity(authDir)).resolves.toEqual({
|
||||
e164: null,
|
||||
jid: null,
|
||||
lid: null,
|
||||
});
|
||||
await expect(webAuthExists(authDir)).resolves.toBe(false);
|
||||
await expect(readWebAuthState(authDir)).resolves.toBe("not-linked");
|
||||
await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({
|
||||
state: "not-linked",
|
||||
authAgeMs: null,
|
||||
selfId: { e164: null, jid: null, lid: null },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"treats creds under a symlinked auth directory as missing",
|
||||
async () => {
|
||||
const rootDir = createTempAuthDir("openclaw-wa-auth-symlink-parent");
|
||||
const targetAuthDir = path.join(rootDir, "target-auth");
|
||||
const authDir = path.join(rootDir, "linked-auth");
|
||||
fsSync.mkdirSync(targetAuthDir);
|
||||
fsSync.writeFileSync(
|
||||
path.join(targetAuthDir, "creds.json"),
|
||||
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
fsSync.symlinkSync(targetAuthDir, authDir, "dir");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
|
||||
expect(fsSync.lstatSync(authDir).isSymbolicLink()).toBe(true);
|
||||
expect(fsSync.lstatSync(credsPath).isFile()).toBe(true);
|
||||
expect(hasWebCredsSync(authDir)).toBe(false);
|
||||
expect(readCredsJsonRaw(credsPath)).toBeNull();
|
||||
await expect(webAuthExists(authDir)).resolves.toBe(false);
|
||||
await expect(readWebAuthState(authDir)).resolves.toBe("not-linked");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports unstable auth state when the shared barrier read times out", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state");
|
||||
fsSync.writeFileSync(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
|
||||
@@ -8,7 +7,15 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveOAuthDir } from "./auth-store.runtime.js";
|
||||
import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js";
|
||||
import {
|
||||
hasWebCredsSync,
|
||||
isWebCredsPathRegularFileOrMissing,
|
||||
readWebCredsJsonRaw,
|
||||
readWebCredsJsonRawSync,
|
||||
resolveWebCredsBackupPath,
|
||||
resolveWebCredsPath,
|
||||
statWebCredsFileSync,
|
||||
} from "./creds-files.js";
|
||||
import {
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
type CredsQueueWaitResult,
|
||||
@@ -39,18 +46,7 @@ export function resolveDefaultWebAuthDir(): string {
|
||||
export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir();
|
||||
|
||||
export function readCredsJsonRaw(filePath: string): string | null {
|
||||
try {
|
||||
if (!fsSync.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const stats = fsSync.statSync(filePath);
|
||||
if (!stats.isFile() || stats.size <= 1) {
|
||||
return null;
|
||||
}
|
||||
return fsSync.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return readWebCredsJsonRawSync(filePath);
|
||||
}
|
||||
|
||||
async function waitForWebAuthBarrier(
|
||||
@@ -75,6 +71,9 @@ export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<b
|
||||
try {
|
||||
const credsPath = resolveWebCredsPath(authDir);
|
||||
const backupPath = resolveWebCredsBackupPath(authDir);
|
||||
if (!isWebCredsPathRegularFileOrMissing(credsPath)) {
|
||||
return false;
|
||||
}
|
||||
const raw = readCredsJsonRaw(credsPath);
|
||||
if (raw) {
|
||||
// Validate that creds.json is parseable.
|
||||
@@ -86,10 +85,6 @@ export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<b
|
||||
if (!backupRaw) {
|
||||
return false;
|
||||
}
|
||||
const backupStats = await fs.lstat(backupPath).catch(() => null);
|
||||
if (!backupStats?.isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure backup is parseable before restoring.
|
||||
JSON.parse(backupRaw);
|
||||
@@ -111,17 +106,11 @@ export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<b
|
||||
export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
const resolvedAuthDir = resolveUserPath(authDir);
|
||||
const credsPath = resolveWebCredsPath(resolvedAuthDir);
|
||||
try {
|
||||
await fs.access(resolvedAuthDir);
|
||||
} catch {
|
||||
const raw = await readWebCredsJsonRaw(credsPath);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(credsPath);
|
||||
if (!stats.isFile() || stats.size <= 1) {
|
||||
return false;
|
||||
}
|
||||
const raw = await fs.readFile(credsPath, "utf-8");
|
||||
JSON.parse(raw);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -382,10 +371,10 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
|
||||
try {
|
||||
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
|
||||
if (!fsSync.existsSync(credsPath)) {
|
||||
const raw = readCredsJsonRaw(credsPath);
|
||||
if (!raw) {
|
||||
return emptyWebSelfId();
|
||||
}
|
||||
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
|
||||
const identity = resolveComparableIdentity(
|
||||
{
|
||||
@@ -409,25 +398,28 @@ export async function readWebSelfIdentity(
|
||||
fallback?: { id?: string | null; lid?: string | null } | null,
|
||||
): Promise<WhatsAppSelfIdentity> {
|
||||
const resolvedAuthDir = resolveUserPath(authDir);
|
||||
try {
|
||||
const raw = await fs.readFile(resolveWebCredsPath(resolvedAuthDir), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
|
||||
return resolveComparableIdentity(
|
||||
{
|
||||
jid: parsed?.me?.id ?? null,
|
||||
lid: parsed?.me?.lid ?? null,
|
||||
},
|
||||
resolvedAuthDir,
|
||||
);
|
||||
} catch {
|
||||
return resolveComparableIdentity(
|
||||
{
|
||||
jid: fallback?.id ?? null,
|
||||
lid: fallback?.lid ?? null,
|
||||
},
|
||||
resolvedAuthDir,
|
||||
);
|
||||
const raw = await readWebCredsJsonRaw(resolveWebCredsPath(resolvedAuthDir));
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
|
||||
return resolveComparableIdentity(
|
||||
{
|
||||
jid: parsed?.me?.id ?? null,
|
||||
lid: parsed?.me?.lid ?? null,
|
||||
},
|
||||
resolvedAuthDir,
|
||||
);
|
||||
} catch {
|
||||
// Fall through to the live message identity below when cached creds are corrupt.
|
||||
}
|
||||
}
|
||||
return resolveComparableIdentity(
|
||||
{
|
||||
jid: fallback?.id ?? null,
|
||||
lid: fallback?.lid ?? null,
|
||||
},
|
||||
resolvedAuthDir,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readWebSelfIdentityForDecision(
|
||||
@@ -450,12 +442,8 @@ export async function readWebSelfIdentityForDecision(
|
||||
* Helpful for heartbeats/observability to spot stale credentials.
|
||||
*/
|
||||
export function getWebAuthAgeMs(authDir: string = resolveDefaultWebAuthDir()): number | null {
|
||||
try {
|
||||
const stats = fsSync.statSync(resolveWebCredsPath(resolveUserPath(authDir)));
|
||||
return Date.now() - stats.mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const stats = statWebCredsFileSync(resolveWebCredsPath(resolveUserPath(authDir)));
|
||||
return stats ? Math.max(0, Date.now() - stats.mtimeMs) : null;
|
||||
}
|
||||
|
||||
export function logWebSelfId(
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
pathExists: vi.fn(async () => false),
|
||||
hasWebCredsSync: vi.fn(() => false),
|
||||
readWebAuthState: vi.fn(async (): Promise<"linked" | "not-linked" | "unstable"> => "not-linked"),
|
||||
readWebAuthExistsForDecision: vi.fn(
|
||||
async (): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> => ({
|
||||
@@ -80,7 +80,6 @@ vi.mock("openclaw/plugin-sdk/setup", async () => {
|
||||
return [...normalized];
|
||||
},
|
||||
normalizeE164,
|
||||
pathExists: hoisted.pathExists,
|
||||
splitSetupEntries: splitSetupEntriesForMock,
|
||||
setSetupChannelEnabled: (cfg: OpenClawConfig, channel: string, enabled: boolean) => ({
|
||||
...cfg,
|
||||
@@ -95,6 +94,14 @@ vi.mock("openclaw/plugin-sdk/setup", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./creds-files.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./creds-files.js")>("./creds-files.js");
|
||||
return {
|
||||
...actual,
|
||||
hasWebCredsSync: hoisted.hasWebCredsSync,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./accounts.js")>("./accounts.js");
|
||||
return {
|
||||
@@ -146,7 +153,7 @@ function createSeparatePhoneHarness(params: { selectValues: string[]; textValues
|
||||
}
|
||||
|
||||
async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues?: string[] }) {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: params.selectValues,
|
||||
textValues: params.textValues,
|
||||
@@ -160,8 +167,8 @@ async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues
|
||||
describe("whatsapp setup wizard", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.loginWeb.mockReset();
|
||||
hoisted.pathExists.mockReset();
|
||||
hoisted.pathExists.mockResolvedValue(false);
|
||||
hoisted.hasWebCredsSync.mockReset();
|
||||
hoisted.hasWebCredsSync.mockReturnValue(false);
|
||||
hoisted.readWebAuthState.mockReset();
|
||||
hoisted.readWebAuthState.mockResolvedValue("not-linked");
|
||||
hoisted.readWebAuthExistsForDecision.mockReset();
|
||||
@@ -233,7 +240,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("enables allowlist self-chat mode for personal-phone setup", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter);
|
||||
|
||||
const result = await runConfigureWithHarness({
|
||||
@@ -244,7 +251,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("throws a user-facing error instead of crashing when personal-phone input is undefined", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter);
|
||||
harness.text.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
@@ -256,7 +263,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: ["separate", "open"],
|
||||
});
|
||||
@@ -334,7 +341,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("writes default-account DM config into accounts.default for multi-account setups", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: ["separate", "open"],
|
||||
});
|
||||
@@ -362,7 +369,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("updates an existing mixed-case default-account key during setup", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: ["separate", "open"],
|
||||
});
|
||||
@@ -392,7 +399,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("runs WhatsApp login when not linked and user confirms linking", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(false);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(false);
|
||||
const harness = createWhatsAppLinkingHarness(createQueuedWizardPrompter);
|
||||
const runtime = createRuntime();
|
||||
|
||||
@@ -405,7 +412,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("skips relink note when already linked and relink is declined", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(true);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: ["separate", "disabled"],
|
||||
});
|
||||
@@ -419,7 +426,7 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("shows follow-up login command note when not linked and linking is skipped", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(false);
|
||||
hoisted.hasWebCredsSync.mockReturnValue(false);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: ["separate", "disabled"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
assertNoSymlinkParentsSync,
|
||||
readRegularFile,
|
||||
readRegularFileSync,
|
||||
statRegularFileSync,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export function resolveWebCredsPath(authDir: string): string {
|
||||
return path.join(authDir, "creds.json");
|
||||
@@ -9,11 +14,89 @@ export function resolveWebCredsBackupPath(authDir: string): string {
|
||||
return path.join(authDir, "creds.json.bak");
|
||||
}
|
||||
|
||||
export function hasWebCredsSync(authDir: string): boolean {
|
||||
function assertWebCredsParentPathSafe(filePath: string): void {
|
||||
const dir = path.resolve(path.dirname(filePath));
|
||||
assertNoSymlinkParentsSync({
|
||||
rootDir: path.parse(dir).root,
|
||||
targetPath: dir,
|
||||
allowMissing: true,
|
||||
allowRootChildSymlink: true,
|
||||
requireDirectories: true,
|
||||
messagePrefix: "WhatsApp credential file path",
|
||||
});
|
||||
}
|
||||
|
||||
export function assertWebCredsPathRegularFileOrMissing(filePath: string): void {
|
||||
try {
|
||||
const stats = fsSync.statSync(resolveWebCredsPath(authDir));
|
||||
return stats.isFile() && stats.size > 1;
|
||||
assertWebCredsParentPathSafe(filePath);
|
||||
statRegularFileSync(filePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`WhatsApp credential file path is unsafe; creds.json must be a regular file or missing: ${filePath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebCredsPathRegularFileOrMissing(filePath: string): boolean {
|
||||
try {
|
||||
assertWebCredsPathRegularFileOrMissing(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readWebCredsJsonRawSync(filePath: string): string | null {
|
||||
try {
|
||||
assertWebCredsParentPathSafe(filePath);
|
||||
const { buffer, stat } = readRegularFileSync({
|
||||
filePath,
|
||||
});
|
||||
return stat.size > 1 ? buffer.toString("utf-8") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWebCredsJsonRaw(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
assertWebCredsParentPathSafe(filePath);
|
||||
const { buffer, stat } = await readRegularFile({
|
||||
filePath,
|
||||
});
|
||||
return stat.size > 1 ? buffer.toString("utf-8") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function statWebCredsFileSync(filePath: string): { mtimeMs: number; size: number } | null {
|
||||
try {
|
||||
assertWebCredsParentPathSafe(filePath);
|
||||
const result = statRegularFileSync(filePath);
|
||||
if (result.missing || result.stat.size <= 1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mtimeMs: result.stat.mtimeMs,
|
||||
size: result.stat.size,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWebCredsRegularFileSync(authDir: string): boolean {
|
||||
try {
|
||||
const credsPath = resolveWebCredsPath(authDir);
|
||||
assertWebCredsParentPathSafe(credsPath);
|
||||
return !statRegularFileSync(credsPath).missing;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWebCredsSync(authDir: string): boolean {
|
||||
return statWebCredsFileSync(resolveWebCredsPath(authDir)) !== null;
|
||||
}
|
||||
|
||||
@@ -127,60 +127,6 @@ function mockFsOpenForCredsWrites(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
function mockCredsJsonSpies(readContents: string) {
|
||||
const credsSuffix = path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json");
|
||||
const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {});
|
||||
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
|
||||
if (typeof p !== "string") {
|
||||
return false;
|
||||
}
|
||||
return p.endsWith(credsSuffix);
|
||||
});
|
||||
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith(credsSuffix)) {
|
||||
return { isFile: () => true, size: 12 } as never;
|
||||
}
|
||||
throw new Error(`unexpected statSync path: ${String(p)}`);
|
||||
});
|
||||
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith(credsSuffix)) {
|
||||
return readContents as never;
|
||||
}
|
||||
throw new Error(`unexpected readFileSync path: ${String(p)}`);
|
||||
});
|
||||
return {
|
||||
copySpy,
|
||||
credsSuffix,
|
||||
restore: () => {
|
||||
copySpy.mockRestore();
|
||||
existsSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
readSpy.mockRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockLogWebSelfIdCreds(me: Record<string, string>) {
|
||||
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
|
||||
if (typeof p !== "string") {
|
||||
return false;
|
||||
}
|
||||
return p.endsWith("creds.json");
|
||||
});
|
||||
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith("creds.json")) {
|
||||
return JSON.stringify({ me });
|
||||
}
|
||||
throw new Error(`unexpected readFileSync path: ${String(p)}`);
|
||||
});
|
||||
return {
|
||||
restore() {
|
||||
existsSpy.mockRestore();
|
||||
readSpy.mockRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstMockCall(
|
||||
mock: { mock: { calls: Array<readonly unknown[]> } },
|
||||
label: string,
|
||||
@@ -321,6 +267,71 @@ describe("web session", () => {
|
||||
openMock.restore();
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects symlinked creds before Baileys auth state reads",
|
||||
async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-creds-symlink-runtime");
|
||||
const targetPath = path.join(authDir, "target-creds.json");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
fsSync.writeFileSync(
|
||||
targetPath,
|
||||
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
fsSync.symlinkSync(targetPath, credsPath);
|
||||
|
||||
await expect(createWaSocket(false, false, { authDir })).rejects.toThrow(
|
||||
"creds.json must be a regular file or missing",
|
||||
);
|
||||
|
||||
expect(useMultiFileAuthStateMock).not.toHaveBeenCalled();
|
||||
expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true);
|
||||
expect(fsSync.readFileSync(targetPath, "utf-8")).toContain("15551234567");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects symlinked auth directories before Baileys auth state reads",
|
||||
async () => {
|
||||
const rootDir = createTempAuthDir("openclaw-wa-authdir-symlink-runtime");
|
||||
const targetAuthDir = path.join(rootDir, "target-auth");
|
||||
const authDir = path.join(rootDir, "linked-auth");
|
||||
fsSync.mkdirSync(targetAuthDir);
|
||||
fsSync.writeFileSync(
|
||||
path.join(targetAuthDir, "creds.json"),
|
||||
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
fsSync.symlinkSync(targetAuthDir, authDir, "dir");
|
||||
|
||||
await expect(createWaSocket(false, false, { authDir })).rejects.toThrow(
|
||||
"creds.json must be a regular file or missing",
|
||||
);
|
||||
|
||||
expect(useMultiFileAuthStateMock).not.toHaveBeenCalled();
|
||||
expect(fsSync.lstatSync(authDir).isSymbolicLink()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects symlinked auth directory parents before creating the auth directory",
|
||||
async () => {
|
||||
const rootDir = createTempAuthDir("openclaw-wa-auth-parent-symlink-runtime");
|
||||
const targetBaseDir = path.join(rootDir, "target-base");
|
||||
const linkedBaseDir = path.join(rootDir, "linked-base");
|
||||
const authDir = path.join(linkedBaseDir, "default");
|
||||
fsSync.mkdirSync(targetBaseDir);
|
||||
fsSync.symlinkSync(targetBaseDir, linkedBaseDir, "dir");
|
||||
|
||||
await expect(createWaSocket(false, false, { authDir })).rejects.toThrow(
|
||||
"creds.json must be a regular file or missing",
|
||||
);
|
||||
|
||||
expect(useMultiFileAuthStateMock).not.toHaveBeenCalled();
|
||||
expect(fsSync.existsSync(path.join(targetBaseDir, "default"))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("passes explicit Baileys socket timing overrides", async () => {
|
||||
await createWaSocket(false, false, {
|
||||
keepAliveIntervalMs: 10_000,
|
||||
@@ -450,37 +461,47 @@ describe("web session", () => {
|
||||
});
|
||||
|
||||
it("logWebSelfId prints cached E.164 when creds exist", () => {
|
||||
const creds = mockLogWebSelfIdCreds({ id: "12345@s.whatsapp.net" });
|
||||
const authDir = createTempAuthDir("openclaw-wa-log-self");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json"),
|
||||
JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
logWebSelfId("/tmp/wa-creds", runtime as never, true);
|
||||
logWebSelfId(authDir, runtime as never, true);
|
||||
|
||||
expectRuntimeLogContaining(runtime, "Web Channel: +12345 (jid 12345@s.whatsapp.net)");
|
||||
creds.restore();
|
||||
});
|
||||
|
||||
it("logWebSelfId prints cached lid details when creds include a lid", () => {
|
||||
const creds = mockLogWebSelfIdCreds({
|
||||
id: "12345@s.whatsapp.net",
|
||||
lid: "777@lid",
|
||||
});
|
||||
const authDir = createTempAuthDir("openclaw-wa-log-self-lid");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json"),
|
||||
JSON.stringify({
|
||||
me: {
|
||||
id: "12345@s.whatsapp.net",
|
||||
lid: "777@lid",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
logWebSelfId("/tmp/wa-creds", runtime as never, true);
|
||||
logWebSelfId(authDir, runtime as never, true);
|
||||
|
||||
expectRuntimeLogContaining(
|
||||
runtime,
|
||||
"Web Channel: +12345 (jid 12345@s.whatsapp.net, lid 777@lid)",
|
||||
);
|
||||
creds.restore();
|
||||
});
|
||||
|
||||
it("formatError prints Boom-like payload message", () => {
|
||||
@@ -503,18 +524,20 @@ describe("web session", () => {
|
||||
});
|
||||
|
||||
it("does not clobber creds backup when creds.json is corrupted", async () => {
|
||||
const creds = mockCredsJsonSpies("{");
|
||||
const authDir = createTempAuthDir("openclaw-wa-corrupt-backup");
|
||||
const backupPath = path.join(authDir, "creds.json.bak");
|
||||
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{", "utf-8");
|
||||
const openMock = mockFsOpenForCredsWrites();
|
||||
|
||||
await createWaSocket(false, false);
|
||||
await emitCredsUpdate();
|
||||
await waitForCredsSaveQueue();
|
||||
try {
|
||||
await createWaSocket(false, false, { authDir });
|
||||
await emitCredsUpdate(authDir);
|
||||
|
||||
expect(creds.copySpy).not.toHaveBeenCalled();
|
||||
expect(openMock.tempHandles).toHaveLength(1);
|
||||
|
||||
creds.restore();
|
||||
openMock.restore();
|
||||
expect(fsSync.existsSync(backupPath)).toBe(false);
|
||||
expect(openMock.tempHandles).toHaveLength(1);
|
||||
} finally {
|
||||
openMock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes creds.update saves to avoid overlapping writes", async () => {
|
||||
@@ -553,7 +576,7 @@ describe("web session", () => {
|
||||
|
||||
await waitForCredsSaveQueue(authDir);
|
||||
|
||||
expect(openMock.tempHandles).toHaveLength(2);
|
||||
expect(openMock.tempHandles).toHaveLength(3);
|
||||
expect(maxInFlight).toBe(1);
|
||||
expect(inFlight).toBe(0);
|
||||
openMock.restore();
|
||||
@@ -612,28 +635,21 @@ describe("web session", () => {
|
||||
});
|
||||
|
||||
it("rotates creds backup when creds.json is valid JSON", async () => {
|
||||
const creds = mockCredsJsonSpies("{}");
|
||||
const authDir = createTempAuthDir("openclaw-wa-rotate-backup");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
const backupPath = path.join(authDir, "creds.json.bak");
|
||||
fsSync.writeFileSync(credsPath, "{}", "utf-8");
|
||||
const openMock = mockFsOpenForCredsWrites();
|
||||
const backupSuffix = path.join(
|
||||
"/tmp",
|
||||
"openclaw-oauth",
|
||||
"whatsapp",
|
||||
"default",
|
||||
"creds.json.bak",
|
||||
);
|
||||
|
||||
await createWaSocket(false, false);
|
||||
await emitCredsUpdate();
|
||||
await waitForCredsSaveQueue();
|
||||
try {
|
||||
await createWaSocket(false, false, { authDir });
|
||||
await emitCredsUpdate(authDir);
|
||||
|
||||
expect(creds.copySpy).toHaveBeenCalledTimes(1);
|
||||
const [sourcePath, backupPath] = firstMockCall(creds.copySpy, "creds backup copy");
|
||||
expect(requireString(sourcePath, "creds backup source path")).toContain(creds.credsSuffix);
|
||||
expect(requireString(backupPath, "creds backup target path")).toContain(backupSuffix);
|
||||
expect(openMock.tempHandles).toHaveLength(1);
|
||||
|
||||
creds.restore();
|
||||
openMock.restore();
|
||||
expect(fsSync.readFileSync(backupPath, "utf-8")).toBe("{}");
|
||||
expect(openMock.tempHandles).toHaveLength(2);
|
||||
} finally {
|
||||
openMock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes creds.json atomically via temp file and rename", async () => {
|
||||
@@ -715,7 +731,14 @@ describe("web session", () => {
|
||||
.readdirSync(authDir)
|
||||
.filter((entry) => entry.startsWith(".creds.") && entry.endsWith(".tmp"));
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledOnce();
|
||||
const primaryRenameCalls = renameSpy.mock.calls.filter(
|
||||
([from, to]) =>
|
||||
typeof from === "string" &&
|
||||
typeof to === "string" &&
|
||||
from.startsWith(path.join(authDir, ".creds.")) &&
|
||||
to === credsPath,
|
||||
);
|
||||
expect(primaryRenameCalls).toHaveLength(1);
|
||||
const parsedCreds = JSON.parse(raw) as unknown;
|
||||
expect(parsedCreds).toEqual(originalCreds);
|
||||
expect(tempEntries).toHaveLength(0);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import type { Agent } from "node:https";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { danger, success } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
readCredsJsonRaw,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveWebCredsBackupPath,
|
||||
resolveWebCredsPath,
|
||||
} from "./auth-store.js";
|
||||
import { assertWebCredsPathRegularFileOrMissing } from "./creds-files.js";
|
||||
import {
|
||||
enqueueCredsSave,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
@@ -71,6 +72,10 @@ const WHATSAPP_WEBSOCKET_PROXY_TARGET = "https://mmg.whatsapp.net/";
|
||||
const CREDS_FLUSH_TIMEOUT_MESSAGE =
|
||||
"Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds.";
|
||||
|
||||
function rejectUnsafeWebCredsPath(authDir: string): void {
|
||||
assertWebCredsPathRegularFileOrMissing(resolveWebCredsPath(authDir));
|
||||
}
|
||||
|
||||
function enqueueSaveCreds(
|
||||
authDir: string,
|
||||
saveCreds: () => Promise<void> | void,
|
||||
@@ -99,12 +104,15 @@ async function safeSaveCreds(
|
||||
if (raw) {
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
fsSync.copyFileSync(credsPath, backupPath);
|
||||
try {
|
||||
fsSync.chmodSync(backupPath, 0o600);
|
||||
} catch {
|
||||
// best-effort on platforms that support it
|
||||
}
|
||||
await replaceFileAtomic({
|
||||
filePath: backupPath,
|
||||
content: raw,
|
||||
dirMode: 0o700,
|
||||
mode: 0o600,
|
||||
tempPrefix: ".creds.backup",
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
});
|
||||
} catch {
|
||||
// keep existing backup
|
||||
}
|
||||
@@ -144,14 +152,17 @@ export async function createWaSocket(
|
||||
);
|
||||
const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent");
|
||||
const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir());
|
||||
rejectUnsafeWebCredsPath(authDir);
|
||||
await ensureDir(authDir);
|
||||
const sessionLogger = getChildLogger({ module: "web-session" });
|
||||
const queueResult = await waitForCredsSaveQueueWithTimeout(authDir);
|
||||
if (queueResult === "timed_out") {
|
||||
sessionLogger.warn({ authDir }, CREDS_FLUSH_TIMEOUT_MESSAGE);
|
||||
} else {
|
||||
rejectUnsafeWebCredsPath(authDir);
|
||||
await restoreCredsFromBackupIfNeeded(authDir);
|
||||
}
|
||||
rejectUnsafeWebCredsPath(authDir);
|
||||
const { state } = await useMultiFileAuthState(authDir);
|
||||
const saveCreds = async () => {
|
||||
await writeCredsJsonAtomically(authDir, state.creds);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
pathExists,
|
||||
splitSetupEntries,
|
||||
createSetupTranslator,
|
||||
type DmPolicy,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppAuthDir,
|
||||
} from "./accounts.js";
|
||||
import { hasWebCredsSync } from "./creds-files.js";
|
||||
import {
|
||||
normalizeWhatsAppAllowFromEntries,
|
||||
normalizeWhatsAppAllowFromEntry,
|
||||
@@ -159,8 +158,7 @@ function setWhatsAppSelfChatMode(
|
||||
|
||||
async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise<boolean> {
|
||||
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
return await pathExists(credsPath);
|
||||
return hasWebCredsSync(authDir);
|
||||
}
|
||||
|
||||
async function promptWhatsAppOwnerAllowFrom(params: {
|
||||
|
||||
Reference in New Issue
Block a user